diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index e2466ec3e559da..87efa82f31d3dc 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -34,4 +34,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }} + VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }} + NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }} \ No newline at end of file diff --git a/.yarn/versions/1085cfbb.yml b/.yarn/versions/1085cfbb.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/1085cfbb.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 87c2b98682e4c0..faffb6192c4416 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/modules/atoms/atoms.module.ts b/apps/api/v2/src/modules/atoms/atoms.module.ts index c7071eabbb1cc2..2d825de5e57d2f 100644 --- a/apps/api/v2/src/modules/atoms/atoms.module.ts +++ b/apps/api/v2/src/modules/atoms/atoms.module.ts @@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisService } from "@/modules/redis/redis.service"; import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; @@ -21,6 +22,7 @@ import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule], providers: [ + OrganizationsTeamsRepository, EventTypesAtomService, ConferencingAtomsService, AttributesAtomsService, diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index d5613afabd647b..36d3c9d56143ab 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/ import { AtomsRepository } from "@/modules/atoms/atoms.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; @@ -53,7 +54,8 @@ export class EventTypesAtomService { private readonly dbWrite: PrismaWriteService, private readonly dbRead: PrismaReadService, private readonly eventTypeService: EventTypesService_2024_06_14, - private readonly teamEventTypeService: TeamsEventTypesService + private readonly teamEventTypeService: TeamsEventTypesService, + private readonly organizationsTeamsRepository: OrganizationsTeamsRepository ) {} private async getTeamSlug(teamId: number): Promise { @@ -88,10 +90,12 @@ export class EventTypesAtomService { throw new NotFoundException(`Event type with id ${eventTypeId} not found`); } - if (eventType?.team?.id) { - await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id); - } else { - this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType); + if (!isUserOrganizationAdmin) { + if (eventType?.team?.id) { + await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id); + } else { + this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType); + } } // note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type @@ -115,7 +119,8 @@ export class EventTypesAtomService { user: UserWithProfile, teamId: number ) { - await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId); + await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId); + const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user); const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined; @@ -175,14 +180,31 @@ export class EventTypesAtomService { } async checkCanUpdateTeamEventType( - userId: number, + user: UserWithProfile, eventTypeId: number, teamId: number, scheduleId: number | null | undefined ) { - await this.checkTeamOwnsEventType(userId, eventTypeId, teamId); + const organizationId = this.usersService.getUserMainOrgId(user); + + if (organizationId) { + const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin( + user.id, + organizationId + ); + + if (isUserOrganizationAdmin) { + const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId); + if (orgTeam) { + await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId); + return; + } + } + } + + await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId); await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId); - await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId); + await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId); } async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) { diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index a8af33579b060c..2e5e818cfd6222 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -27,6 +27,17 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } + async isOrgAdminOrOwner(organizationId: number, userId: number) { + const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( + organizationId, + userId + ); + if (!membership) { + return false; + } + return membership.role === "ADMIN" || membership.role === "OWNER"; + } + async getOrgMembershipByUserId(organizationId: number, userId: number) { const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( organizationId, diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts index 25cda044c06edd..1c4411eb318c63 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts @@ -14,6 +14,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input"; import { @@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client"; @ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) @ApiHeader(OPTIONAL_API_KEY_HEADER) export class OrganizationsTeamsController { - constructor(private organizationsTeamsService: OrganizationsTeamsService) {} + constructor( + private organizationsTeamsService: OrganizationsTeamsService, + private organizationsMembershipService: OrganizationsMembershipService + ) {} @Get() @ApiOperation({ summary: "Get all teams" }) @@ -84,12 +88,11 @@ export class OrganizationsTeamsController { @GetUser() user: UserWithProfile ): Promise { const { skip, take } = queryParams; - const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams( - orgId, - user.id, - skip ?? 0, - take ?? 250 - ); + const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id); + const teams = isOrgAdminOrOwner + ? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250) + : await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250); + return { status: SUCCESS_STATUS, data: teams.map((team) => { diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts index 9acf452aea51d2..27f905f3a86824 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts @@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository { take, }); } + + async findOrgTeamsPaginatedWithMembers(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + }, + include: { + members: { select: { accepted: true, userId: true, role: true } }, + }, + skip, + take, + }); + } } diff --git a/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts b/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts index a9f305cb456c8d..5a5ea2e5a2d6f0 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts @@ -24,6 +24,15 @@ export class OrganizationsTeamsService { return teams; } + async getPaginatedOrgTeamsWithMembers(organizationId: number, skip = 0, take = 250) { + const teams = await this.organizationsTeamRepository.findOrgTeamsPaginatedWithMembers( + organizationId, + skip, + take + ); + return teams; + } + async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) { const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take); return teams; diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx index f07d76de41a385..c0b7bc8093aac0 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart"; import { ChartCard } from "@calcom/features/insights/components/ChartCard"; +import { BookingsByHourChartContent } from "@calcom/features/insights/components/booking/BookingsByHourChart"; import { useLocale } from "@calcom/lib/hooks/useLocale"; // Sample data for playground testing diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx index 3f3c61bbd815df..ba92702cf13069 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx @@ -1,7 +1,10 @@ "use client"; import { ChartCard } from "@calcom/features/insights/components/ChartCard"; -import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent"; +import { + RoutingFunnelContent, + legend, +} from "@calcom/features/insights/components/routing/RoutingFunnelContent"; import { useLocale } from "@calcom/lib/hooks/useLocale"; // Random sample data for playground testing diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts index adeda4a091febe..723182ac4d6cbc 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts @@ -44,5 +44,17 @@ describe("usePermissions", () => { expect(getResourcePermissionLevel("eventType", permissions)).toBe("all"); }); + + it("should return 'all' for resource with manage permission", () => { + const permissions = ["eventType.manage"]; + + expect(getResourcePermissionLevel("eventType", permissions)).toBe("all"); + }); + + it("should return 'all' for resource with manage permission even if other permissions are missing", () => { + const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete + + expect(getResourcePermissionLevel("eventType", permissions)).toBe("all"); + }); }); }); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts index ade70cd4e26afc..7200b79475ee17 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts @@ -51,14 +51,20 @@ export function usePermissions(): UsePermissionsReturn { return "all"; } - // Filter out internal keys like _resource when checking permissions - const allResourcePerms = Object.keys(resourceConfig) - .filter((action) => !action.startsWith("_")) + // Check if user has manage permission for this resource + const hasManagePermission = permissions.includes(`${resource}.manage`); + if (hasManagePermission) { + return "all"; + } + + // Filter out internal keys like _resource and manage when checking for individual permissions + const crudPermissions = Object.keys(resourceConfig) + .filter((action) => !action.startsWith("_") && action !== "manage") .map((action) => `${resource}.${action}`); - const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p)); + const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p)); const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`); - if (hasAllPerms) return "all"; + if (hasAllCrudPerms) return "all"; if (hasReadPerm) return "read"; return "none"; }; diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 01c0264e88e471..f172d15721ab6d 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -515,7 +515,9 @@ function BookingListItem(booking: BookingItemProps) { {!isPending && (
- {(provider?.label || locationToDisplay?.startsWith("https://")) && + {(provider?.label || + (typeof locationToDisplay === "string" && + locationToDisplay?.startsWith("https://"))) && locationToDisplay.startsWith("http") && ( { await page.click("[data-testid=vertical-tab-event_advanced_tab_title]"); await expect(offerSeatsToggle).toBeDisabled(); }); + test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({ + page, + users, + }) => { + await gotoFirstEventType(page); + await expect(page.locator("[data-testid=event-title]")).toBeVisible(); + await page.click("[data-testid=vertical-tab-event_advanced_tab_title]"); + await page.click("[data-testid=lock-timezone-toggle]"); + await page.click("[data-testid=timezone-select]"); + await page.locator('[aria-label="Timezone Select"]').fill("New York"); + await page.keyboard.press("Enter"); + + await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", { + action: () => page.locator("[data-testid=update-eventtype]").click(), + }); + await page.goto("/event-types"); + const previewLink = await page + .locator("[data-testid=preview-link-button]") + .first() + .getAttribute("href"); + + await page.goto(previewLink ?? ""); + const currentTimezone = page.locator('[data-testid="event-meta-current-timezone"]'); + await expect(currentTimezone).toBeVisible(); + await expect(currentTimezone).toHaveClass(/cursor-not-allowed/); + await expect(page.getByText("New York")).toBeVisible(); + }); }); test.describe("Interface Language Tests", () => { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4829475ef1bba8..11a55d364c781e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3305,12 +3305,12 @@ "pbac_desc_view_roles": "View roles", "pbac_desc_update_roles": "Update roles", "pbac_desc_delete_roles": "Delete roles", - "pbac_desc_manage_roles": "All actions on roles", + "pbac_desc_manage_roles": "All actions on roles across organization teams", "pbac_desc_create_event_types": "Create event types", "pbac_desc_view_event_types": "View event types", "pbac_desc_update_event_types": "Update event types", "pbac_desc_delete_event_types": "Delete event types", - "pbac_desc_manage_event_types": "All actions on event types", + "pbac_desc_manage_event_types": "All actions on event types across organization teams", "pbac_desc_create_teams": "Create teams", "pbac_desc_view_team_details": "View team details", "pbac_desc_update_team_settings": "Update team settings", @@ -3318,7 +3318,7 @@ "pbac_desc_invite_team_members": "Invite team members", "pbac_desc_remove_team_members": "Remove team members", "pbac_desc_change_team_member_role": "Change role of team members", - "pbac_desc_manage_teams": "All actions on teams", + "pbac_desc_manage_teams": "All actions on teams across organization teams", "pbac_desc_create_organization": "Create organization", "pbac_desc_view_organization_details": "View organization details", "pbac_desc_list_organization_members": "List organization members", @@ -3333,7 +3333,7 @@ "pbac_desc_view_organization_bookings": "View organization bookings", "pbac_desc_view_booking_recordings": "View booking recordings", "pbac_desc_update_bookings": "Update bookings", - "pbac_desc_manage_bookings": "All actions on bookings", + "pbac_desc_manage_bookings": "All actions on bookings across organization teams", "pbac_desc_view_team_insights": "View team insights", "pbac_desc_manage_team_insights": "Manage team insights", "read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource", diff --git a/apps/web/server/lib/[user]/getServerSideProps.ts b/apps/web/server/lib/[user]/getServerSideProps.ts index 26e46ef97f7368..a41a603dedf8cc 100644 --- a/apps/web/server/lib/[user]/getServerSideProps.ts +++ b/apps/web/server/lib/[user]/getServerSideProps.ts @@ -60,6 +60,7 @@ type UserPageProps = { | "length" | "hidden" | "lockTimeZoneToggleOnBookingPage" + | "lockedTimeZone" | "requiresConfirmation" | "canSendCalVideoTranscriptionEmails" | "requiresBookerEmailVerification" diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 0cf671d3c783eb..6f24f398cb62e5 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -438,7 +438,6 @@ describe("handleChildrenEventTypes", () => { schedulingType: SchedulingType.MANAGED, requiresBookerEmailVerification: false, lockTimeZoneToggleOnBookingPage: false, - lockedTimeZone: "Europe/London", useEventTypeDestinationCalendarEmail: false, workflows: [], parentId: 1, diff --git a/docs/developing/open-source-contribution/contributors-guide.mdx b/docs/developing/open-source-contribution/contributors-guide.mdx index 21407196bb2f88..678b1e9f8606c2 100644 --- a/docs/developing/open-source-contribution/contributors-guide.mdx +++ b/docs/developing/open-source-contribution/contributors-guide.mdx @@ -56,11 +56,11 @@ To ensure consistency and make files easy to fuzzy-find, we follow the naming co **Pattern:** -PrismaRepository.ts +Prisma``Repository.ts **Examples:** -```ts +```js // File: PrismaAppRepository.ts export class PrismaAppRepository { ... } @@ -80,11 +80,11 @@ Keep naming specific — avoid generic names like AppService.ts. **Pattern:** -Service.ts +``Service.ts **Examples:** -```ts +```js // File: MembershipService.ts export class MembershipService { ... } diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 07affdc5490917..3231274aae036e 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -58,6 +58,7 @@ export const EventMeta = ({ event?: Pick< BookerEvent, | "lockTimeZoneToggleOnBookingPage" + | "lockedTimeZone" | "schedule" | "seatsPerTimeSlot" | "subsetOfUsers" @@ -112,9 +113,12 @@ export const EventMeta = ({ ); useEffect(() => { - //In case the event has lockTimeZone enabled ,set the timezone to event's attached availability timezone - if (event && event?.lockTimeZoneToggleOnBookingPage && event?.schedule?.timeZone) { - setTimezone(event.schedule?.timeZone); + //In case the event has lockTimeZone enabled ,set the timezone to event's locked timezone + if (event?.lockTimeZoneToggleOnBookingPage) { + const timezone = event.lockedTimeZone || event.schedule?.timeZone; + if (timezone) { + setTimezone(timezone); + } } }, [event, setTimezone]); @@ -217,7 +221,8 @@ export const EventMeta = ({ + }`} + data-testid="event-meta-current-timezone"> "ml-auto", container: () => "max-w-full", }} - value={event.lockTimeZoneToggleOnBookingPage ? CURRENT_TIMEZONE : timezone} + value={ + event.lockTimeZoneToggleOnBookingPage + ? event.lockedTimeZone || CURRENT_TIMEZONE + : timezone + } onChange={({ value }) => { setTimezone(value); setBookerStoreTimezone(value); diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 960f0f249c4fac..fb46424865931b 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -65,6 +65,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { periodDays: true, periodCountCalendarDays: true, lockTimeZoneToggleOnBookingPage: true, + lockedTimeZone: true, requiresConfirmation: true, requiresConfirmationForFreeEmail: true, requiresBookerEmailVerification: true, diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 178c769c1cd39a..91647f672f6de5 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -46,6 +46,7 @@ export type BookerEvent = Pick< | "price" | "currency" | "lockTimeZoneToggleOnBookingPage" + | "lockedTimeZone" | "schedule" | "seatsPerTimeSlot" | "title" diff --git a/packages/features/components/timezone-select/TimezoneSelect.tsx b/packages/features/components/timezone-select/TimezoneSelect.tsx index 337f0e008a41c1..6c0a29c6321347 100644 --- a/packages/features/components/timezone-select/TimezoneSelect.tsx +++ b/packages/features/components/timezone-select/TimezoneSelect.tsx @@ -108,6 +108,7 @@ export function TimezoneSelectComponent({ className={`${className} ${timezoneSelectCustomClassname}`} aria-label="Timezone Select" isLoading={isPending} + data-testid="timezone-select" isDisabled={isPending} {...reactSelectProps} timezones={{ diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 7914739d461f56..9b314684161d8d 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -21,6 +21,7 @@ import { getAllRemindersToCancel, getAllRemindersToDelete, getAllUnscheduledReminders, + getWorkflowRecipientEmail, } from "../lib/getWorkflowReminders"; import { sendOrScheduleWorkflowEmails } from "../lib/reminders/providers/emailProvider"; import { @@ -187,6 +188,13 @@ export async function handler(req: NextRequest) { reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null ); + const recipientEmail = getWorkflowRecipientEmail({ + action: reminder.workflowStep.action || WorkflowActions.EMAIL_ADDRESS, + attendeeEmail: reminder.booking.attendees[0].email, + organizerEmail: reminder.booking.user?.email, + sendToEmail: reminder.workflowStep.sendTo, + }); + const variables: VariablesType = { eventName: reminder.booking.eventType?.title || "", organizerName: reminder.booking.user?.name || "", @@ -199,8 +207,12 @@ export async function handler(req: NextRequest) { additionalNotes: reminder.booking.description, responses: responses, meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl, - cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true`, - rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}`, + cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true${ + recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : "" + }`, + rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}${ + recipientEmail ? `?rescheduledBy=${encodeURIComponent(recipientEmail)}` : "" + }`, ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating`, noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true`, attendeeTimezone: reminder.booking.attendees[0].timeZone, diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.ts index feb6a69ae45058..d3ed23d6a14db5 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.ts @@ -16,7 +16,7 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { getSenderId } from "../lib/alphanumericSenderIdSupport"; import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders"; -import { select } from "../lib/getWorkflowReminders"; +import { select, getWorkflowRecipientEmail } from "../lib/getWorkflowReminders"; import type { VariablesType } from "../lib/reminders/templates/customTemplate"; import customTemplate from "../lib/reminders/templates/customTemplate"; import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate"; @@ -108,10 +108,20 @@ export async function handler(req: NextRequest) { reminder.booking.eventType?.team?.parentId ?? organizerOrganizationId ?? null ); + const recipientEmail = getWorkflowRecipientEmail({ + action: reminder.workflowStep.action || WorkflowActions.SMS_NUMBER, + attendeeEmail: reminder.booking.attendees[0].email, + organizerEmail: reminder.booking.user?.email, + }); + const urls = { meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl || "", - cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true` || "", - rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}` || "", + cancelLink: `${bookerUrl}/booking/${reminder.booking.uid}?cancel=true${ + recipientEmail ? `&cancelledBy=${recipientEmail}` : "" + }`, + rescheduleLink: `${bookerUrl}/reschedule/${reminder.booking.uid}${ + recipientEmail ? `?rescheduledBy=${recipientEmail}` : "" + }`, }; const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] = diff --git a/packages/features/ee/workflows/lib/getWorkflowReminders.ts b/packages/features/ee/workflows/lib/getWorkflowReminders.ts index ca246583a3baf5..4d4e89b3ec8f9c 100644 --- a/packages/features/ee/workflows/lib/getWorkflowReminders.ts +++ b/packages/features/ee/workflows/lib/getWorkflowReminders.ts @@ -4,7 +4,12 @@ import type { EventType, User, WorkflowReminder, WorkflowStep, Prisma } from "@c import { WorkflowMethods } from "@calcom/prisma/enums"; type PartialWorkflowStep = - | (Partial & { workflow: { userId?: number; teamId?: number } }) + | (Partial & { + workflow: { + userId?: number; + teamId?: number; + }; + }) | null; type Booking = Prisma.BookingGetPayload<{ @@ -219,3 +224,35 @@ export async function getAllUnscheduledReminders(): Promise const bookerUrl = evt.bookerUrl ?? WEBSITE_URL; if (emailBody) { + const recipientEmail = getWorkflowRecipientEmail({ + action, + attendeeEmail: attendeeToBeUsedInMail.email, + organizerEmail: evt.organizer.email, + sendToEmail: sendTo[0], + }); const variables: VariablesType = { eventName: evt.title || "", organizerName: evt.organizer.name, @@ -153,9 +160,13 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => additionalNotes: evt.additionalNotes, responses: evt.responses, meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl, - cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true`, + cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true${ + recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : "" + }`, cancelReason: evt.cancellationReason, - rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}`, + rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}${ + recipientEmail ? `?rescheduledBy=${encodeURIComponent(recipientEmail)}` : "" + }`, rescheduleReason: evt.rescheduleReason, ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`, noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`, diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 2255ae5faffb39..2ff24bcf0b9f09 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -4,6 +4,7 @@ import { WEBSITE_URL } from "@calcom/lib/constants"; import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import { getWorkflowRecipientEmail } from "../getWorkflowReminders"; import type { AttendeeInBookingInfo, BookingInfo } from "./smsReminderManager"; import type { VariablesType } from "./templates/customTemplate"; import customTemplate from "./templates/customTemplate"; @@ -40,10 +41,18 @@ export const getSMSMessageWithVariables = async ( attendeeToBeUsedInSMS: AttendeeInBookingInfo, action: WorkflowActions ) => { + const recipientEmail = getWorkflowRecipientEmail({ + action, + attendeeEmail: attendeeToBeUsedInSMS.email, + }); const urls = { meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "", - cancelLink: `${evt.bookerUrl ?? WEBSITE_URL}/booking/${evt.uid}?cancel=true`, - rescheduleLink: `${evt.bookerUrl ?? WEBSITE_URL}/reschedule/${evt.uid}`, + cancelLink: `${evt.bookerUrl ?? WEBSITE_URL}/booking/${evt.uid}?cancel=true${ + recipientEmail ? `&cancelledBy=${recipientEmail}` : "" + }`, + rescheduleLink: `${evt.bookerUrl ?? WEBSITE_URL}/reschedule/${evt.uid}${ + recipientEmail ? `?rescheduledBy=${recipientEmail}` : "" + }`, }; const [{ shortLink: meetingUrl }, { shortLink: cancelLink }, { shortLink: rescheduleLink }] = diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index bfb21d5644d9ae..dced1ca6193aaa 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -12,6 +12,7 @@ import { } from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper"; import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector"; +import { TimezoneSelect } from "@calcom/features/components/timezone-select"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { allowDisablingAttendeeConfirmationEmails, @@ -1033,23 +1034,63 @@ export const EventAdvancedTab = ({ /> ( - onChange(e)} - data-testid="lock-timezone-toggle" - /> - )} + render={({ field: { value, onChange } }) => { + // Calculate if we should show the selector based on current form state & handle backward compatibility + const currentLockedTimeZone = formMethods.getValues("lockedTimeZone"); + const showSelector = + value && + (!(eventType.lockTimeZoneToggleOnBookingPage && !eventType.lockedTimeZone) || + !!currentLockedTimeZone); + + return ( + { + onChange(e); + const lockedTimeZone = e ? eventType.lockedTimeZone ?? "Europe/London" : null; + formMethods.setValue("lockedTimeZone", lockedTimeZone, { shouldDirty: true }); + }} + data-testid="lock-timezone-toggle" + childrenClassName="lg:ml-0"> + {showSelector && ( +
+
+ ( + <> + + { + if (event) + formMethods.setValue("lockedTimeZone", event.value, { shouldDirty: true }); + }} + /> + + )} + /> +
+
+ )} +
+ ); + }} /> { disableGuests: true, metadata: true, lockTimeZoneToggleOnBookingPage: true, + lockedTimeZone: true, requiresConfirmation: true, autoTranslateDescriptionEnabled: true, fieldTranslations: { diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index be6600c70df748..2092c1a256ab16 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -88,6 +88,7 @@ export type FormValues = { description: string; disableGuests: boolean; lockTimeZoneToggleOnBookingPage: boolean; + lockedTimeZone: string | null; requiresConfirmation: boolean; requiresConfirmationWillBlockSlot: boolean; requiresConfirmationForFreeEmail: boolean; diff --git a/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md b/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md new file mode 100644 index 00000000000000..d20c8a12e758af --- /dev/null +++ b/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md @@ -0,0 +1,177 @@ +# How to Add a New Booking Chart to Cal.com Insights Page + +This guide walks you through creating a new booking chart component for the insights page, covering the entire stack from UI component to backend service. + +## Overview + +The insights booking system follows this architecture: + +``` +UI Component → tRPC Handler → Insights Service → Database Query → Response +``` + +## Step 1: Create the UI Component + +Create your chart component in `packages/features/insights/components/booking/`: + +```typescript +// packages/features/insights/components/booking/MyNewChart.tsx +import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from "recharts"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; + +export const MyNewChart = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, { + staleTime: 180000, // 3 minutes + refetchOnWindowFocus: false, + trpc: { context: { skipBatch: true } }, + }); + + if (isPending) return ; + + return ( + + {isSuccess && data?.length > 0 ? ( + + + + + + + + + + ) : ( +
+

{t("no_data_yet")}

+
+ )} +
+ ); +}; +``` + +## Step 2: Add Component to Barrel Export + +Update the booking components index file: + +```typescript +// packages/features/insights/components/booking/index.ts +export { AverageEventDurationChart } from "./AverageEventDurationChart"; +export { BookingKPICards } from "./BookingKPICards"; +// ... existing exports +export { MyNewChart } from "./MyNewChart"; // Add this line +``` + +## Step 3: Add Component to Insights View + +Add your component to the main insights page: + +```typescript +// apps/web/modules/insights/insights-view.tsx +import { + AverageEventDurationChart, + BookingKPICards, // ... existing imports + MyNewChart, // Add this import +} from "@calcom/features/insights/components/booking"; + +export default function InsightsPage() { + // ... existing code + + return ( +
+ {/* Existing components */} + + + + {/* Add your new chart */} + + + {/* Other existing components */} +
+ ); +} +``` + +## Step 4: Create tRPC Handler + +Add the tRPC endpoint in the insights router using the `createInsightsBookingService()` helper: + +```typescript +// packages/features/insights/server/trpc-router.ts +import { bookingRepositoryBaseInputSchema } from "@calcom/features/insights/server/raw-data.schema"; +import { userBelongsToTeamProcedure } from "@calcom/trpc/server/procedures/authedProcedure"; + +import { TRPCError } from "@trpc/server"; + +export const insightsRouter = router({ + // ... existing procedures + + myNewChartData: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); + + try { + return await insightsBookingService.getMyNewChartData(); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + }), +}); +``` + +## Step 5: Add Service Method to InsightsBookingService + +Add your new method to the `InsightsBookingService` class: + +```typescript +// packages/lib/server/service/insightsBooking.ts +export class InsightsBookingService { + // ... existing methods + + async getMyNewChartData() { + const baseConditions = await this.getBaseConditions(); + + // Example: Get booking counts by day using raw SQL for performance + const data = await this.prisma.$queryRaw< + Array<{ + date: Date; + bookingsCount: number; + }> + >` + SELECT + DATE("createdAt") as date, + COUNT(*)::int as "bookingsCount" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + GROUP BY DATE("createdAt") + ORDER BY date ASC + `; + + // Transform the data for the chart + return data.map((item) => ({ + date: item.date.toISOString().split("T")[0], // Format as YYYY-MM-DD + value: item.bookingsCount, + })); + } +} +``` + +## Best Practices + +1. **Use `createInsightsBookingService()`**: Always use the helper function for consistent service creation +2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance +3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions +4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError` +5. **Loading States**: Always show loading indicators with `LoadingInsight` +6. **Consistent Styling**: Use `recharts` for new charts. +7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts diff --git a/packages/features/insights/components/BookingAtCell.tsx b/packages/features/insights/components/BookingAtCell.tsx index 3dfc7221539111..51813b065be3f8 100644 --- a/packages/features/insights/components/BookingAtCell.tsx +++ b/packages/features/insights/components/BookingAtCell.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { useId } from "react"; diff --git a/packages/features/insights/components/ChartCard.tsx b/packages/features/insights/components/ChartCard.tsx index 415313b60c70cb..d2ae63fd791a9b 100644 --- a/packages/features/insights/components/ChartCard.tsx +++ b/packages/features/insights/components/ChartCard.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Fragment, type ReactNode } from "react"; import classNames from "@calcom/ui/classNames"; diff --git a/packages/features/insights/components/EventTrendsChart.tsx b/packages/features/insights/components/EventTrendsChart.tsx deleted file mode 100644 index c5bdf1b521a225..00000000000000 --- a/packages/features/insights/components/EventTrendsChart.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useDataTable } from "@calcom/features/data-table"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { valueFormatter } from "../lib/valueFormatter"; -import { ChartCard } from "./ChartCard"; -import { LineChart } from "./LineChart"; -import { LoadingInsight } from "./LoadingInsights"; - -export const EventTrendsChart = () => { - const { t } = useLocale(); - const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const { timeZone } = useDataTable(); - - const { - data: eventTrends, - isSuccess, - isPending, - } = trpc.viewer.insights.eventTrends.useQuery( - { - scope, - selectedTeamId, - startDate, - endDate, - timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - if (!isSuccess) return null; - - return ( - - - - ); -}; diff --git a/packages/features/insights/components/FeedbackTable.tsx b/packages/features/insights/components/FeedbackTable.tsx index ea2675f2501990..af48a82ca8bf7a 100644 --- a/packages/features/insights/components/FeedbackTable.tsx +++ b/packages/features/insights/components/FeedbackTable.tsx @@ -1,3 +1,5 @@ +"use client"; + import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { User } from "@calcom/prisma/client"; diff --git a/packages/features/insights/components/HighestNoShowHostTable.tsx b/packages/features/insights/components/HighestNoShowHostTable.tsx deleted file mode 100644 index 7b939dc5784791..00000000000000 --- a/packages/features/insights/components/HighestNoShowHostTable.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; - -export const HighestNoShowHostTable = () => { - const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); - - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - if (!isSuccess || !data) return null; - - return data && data.length > 0 ? ( - - - - ) : ( - <> - ); -}; diff --git a/packages/features/insights/components/KPICard.tsx b/packages/features/insights/components/KPICard.tsx index c7ef294d4549ef..6f1d1ed444fa6f 100644 --- a/packages/features/insights/components/KPICard.tsx +++ b/packages/features/insights/components/KPICard.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Flex, Text, Metric, BadgeDelta } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/packages/features/insights/components/MostCancelledBookingsTables.tsx b/packages/features/insights/components/MostCancelledBookingsTables.tsx deleted file mode 100644 index da5ca0d7681a06..00000000000000 --- a/packages/features/insights/components/MostCancelledBookingsTables.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; - -export const MostCancelledBookingsTables = () => { - const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); - - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - return ( - - {!isSuccess || !startDate || !endDate || !teamId ? null : } - - ); -}; diff --git a/packages/features/insights/components/RecentFeedbackTable.tsx b/packages/features/insights/components/RecentFeedbackTable.tsx deleted file mode 100644 index e72b49eaa8f3ac..00000000000000 --- a/packages/features/insights/components/RecentFeedbackTable.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { FeedbackTable } from "./FeedbackTable"; -import { LoadingInsight } from "./LoadingInsights"; - -export const RecentFeedbackTable = () => { - const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); - - const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - if (!isSuccess || !data) return null; - - return ( - - - - ); -}; diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx deleted file mode 100644 index eb740cfaace18c..00000000000000 --- a/packages/features/insights/components/TotalBookingUsersTable.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; -import type { User } from "@calcom/prisma/client"; -import { Avatar } from "@calcom/ui/components/avatar"; - -import { ChartCardItem } from "./ChartCard"; - -export const TotalBookingUsersTable = ({ - data, -}: { - data: - | { - userId: number | null; - user: Pick; - emailMd5?: string; - count: number; - username?: string; - }[] - | undefined; -}) => { - const filteredData = data && data?.length > 0 ? data?.filter((item) => !!item.user) : []; - return ( -
- {filteredData.length > 0 ? ( - filteredData.map((item, index) => ( - -
- -
{item.user.name}
-
-
- )) - ) : ( -
-

No members found

-
- )} -
- ); -}; diff --git a/packages/features/insights/components/TotalUserFeedbackTable.tsx b/packages/features/insights/components/UserStatsTable.tsx similarity index 69% rename from packages/features/insights/components/TotalUserFeedbackTable.tsx rename to packages/features/insights/components/UserStatsTable.tsx index 4559e3982ca262..6783a651fb1d5a 100644 --- a/packages/features/insights/components/TotalUserFeedbackTable.tsx +++ b/packages/features/insights/components/UserStatsTable.tsx @@ -1,10 +1,13 @@ +"use client"; + import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { User } from "@calcom/prisma/client"; import { Avatar } from "@calcom/ui/components/avatar"; import { ChartCardItem } from "./ChartCard"; -export const TotalUserFeedbackTable = ({ +export const UserStatsTable = ({ data, }: { data: @@ -18,13 +21,19 @@ export const TotalUserFeedbackTable = ({ }[] | undefined; }) => { + const { t } = useLocale(); + + // Filter out items without user data + const filteredData = data && data?.length > 0 ? data?.filter((item) => !!item.user) : []; + return (
- {data && data?.length > 0 ? ( - data?.map((item) => ( + {filteredData.length > 0 ? ( + filteredData.map((item) => ( + key={item.userId || `user-${Math.random()}`} + count={item.averageRating ? item.averageRating.toFixed(1) : item.count} + className="py-3">
-

No data found

+

{t("no_data_yet")}

)}
diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/booking/AverageEventDurationChart.tsx similarity index 59% rename from packages/features/insights/components/AverageEventDurationChart.tsx rename to packages/features/insights/components/booking/AverageEventDurationChart.tsx index 9e0c40eb6e82e1..799226b9ea71a1 100644 --- a/packages/features/insights/components/AverageEventDurationChart.tsx +++ b/packages/features/insights/components/booking/AverageEventDurationChart.tsx @@ -1,31 +1,23 @@ -import { useDataTable } from "@calcom/features/data-table"; +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { valueFormatter } from "../lib/valueFormatter"; -import { ChartCard } from "./ChartCard"; -import { LineChart } from "./LineChart"; -import { LoadingInsight } from "./LoadingInsights"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { valueFormatter } from "../../lib/valueFormatter"; +import { ChartCard } from "../ChartCard"; +import { LineChart } from "../LineChart"; +import { LoadingInsight } from "../LoadingInsights"; export const AverageEventDurationChart = () => { const { t } = useLocale(); - const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const { timeZone } = useDataTable(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery( + insightsBookingParams, { - scope, - selectedTeamId, - startDate, - endDate, - timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, diff --git a/packages/features/insights/components/BookingKPICards.tsx b/packages/features/insights/components/booking/BookingKPICards.tsx similarity index 90% rename from packages/features/insights/components/BookingKPICards.tsx rename to packages/features/insights/components/booking/BookingKPICards.tsx index 7d5030a1f39ec3..43d24f85230b3d 100644 --- a/packages/features/insights/components/BookingKPICards.tsx +++ b/packages/features/insights/components/booking/BookingKPICards.tsx @@ -1,28 +1,23 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import classNames from "@calcom/ui/classNames"; import { SkeletonText } from "@calcom/ui/components/skeleton"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { KPICard } from "./KPICard"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { KPICard } from "../KPICard"; export const BookingKPICards = () => { const { t } = useLocale(); - const { startDate, endDate, teamId, userId, isAll, memberUserId, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.eventsByStatus.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - memberUserId, - userId, - isAll, - }, + const { data, isSuccess, isPending } = trpc.viewer.insights.bookingKPIStats.useQuery( + insightsBookingParams, { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, diff --git a/packages/features/insights/components/BookingsByHourChart.tsx b/packages/features/insights/components/booking/BookingsByHourChart.tsx similarity index 81% rename from packages/features/insights/components/BookingsByHourChart.tsx rename to packages/features/insights/components/booking/BookingsByHourChart.tsx index c344b1c5fa4aea..d3b1f27b98a1e8 100644 --- a/packages/features/insights/components/BookingsByHourChart.tsx +++ b/packages/features/insights/components/booking/BookingsByHourChart.tsx @@ -11,14 +11,12 @@ import { Rectangle, } from "recharts"; -import { useDataTable } from "@calcom/features/data-table"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; type BookingsByHourData = { hour: number; @@ -99,21 +97,13 @@ const CustomTooltip = ({ export const BookingsByHourChart = () => { const { t } = useLocale(); - const { timeZone } = useDataTable(); - const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery( + insightsBookingParams, { - scope, - selectedTeamId, - startDate, - endDate, - eventTypeId, - memberUserId, - timeZone: timeZone || CURRENT_TIMEZONE, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, diff --git a/packages/features/insights/components/booking/EventTrendsChart.tsx b/packages/features/insights/components/booking/EventTrendsChart.tsx new file mode 100644 index 00000000000000..a15cc380c59bb2 --- /dev/null +++ b/packages/features/insights/components/booking/EventTrendsChart.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { valueFormatter } from "../../lib/valueFormatter"; +import { ChartCard } from "../ChartCard"; +import { LineChart } from "../LineChart"; +import { LoadingInsight } from "../LoadingInsights"; + +export const EventTrendsChart = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { + data: eventTrends, + isSuccess, + isPending, + } = trpc.viewer.insights.eventTrends.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + }); + + if (isPending) return ; + + if (!isSuccess) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/booking/HighestNoShowHostTable.tsx b/packages/features/insights/components/booking/HighestNoShowHostTable.tsx new file mode 100644 index 00000000000000..96c16b52cc5f40 --- /dev/null +++ b/packages/features/insights/components/booking/HighestNoShowHostTable.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; + +export const HighestNoShowHostTable = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( + insightsBookingParams, + { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/HighestRatedMembersTable.tsx b/packages/features/insights/components/booking/HighestRatedMembersTable.tsx similarity index 53% rename from packages/features/insights/components/HighestRatedMembersTable.tsx rename to packages/features/insights/components/booking/HighestRatedMembersTable.tsx index 3655c13cfeb74e..8c0a7d8db2a2d0 100644 --- a/packages/features/insights/components/HighestRatedMembersTable.tsx +++ b/packages/features/insights/components/booking/HighestRatedMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const HighestRatedMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const HighestRatedMembersTable = () => { return data && data.length > 0 ? ( - + ) : ( <> diff --git a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx b/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx similarity index 52% rename from packages/features/insights/components/LeastBookedTeamMembersTable.tsx rename to packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx index f7c47d0d1339ad..a44e46251545e6 100644 --- a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const LeastBookedTeamMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const LeastBookedTeamMembersTable = () => { return ( - + ); }; diff --git a/packages/features/insights/components/LowestRatedMembersTable.tsx b/packages/features/insights/components/booking/LowestRatedMembersTable.tsx similarity index 53% rename from packages/features/insights/components/LowestRatedMembersTable.tsx rename to packages/features/insights/components/booking/LowestRatedMembersTable.tsx index 1708150b1b01f2..64dcdeffe2586b 100644 --- a/packages/features/insights/components/LowestRatedMembersTable.tsx +++ b/packages/features/insights/components/booking/LowestRatedMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const LowestRatedMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const LowestRatedMembersTable = () => { return data && data.length > 0 ? ( - + ) : ( <> diff --git a/packages/features/insights/components/MostBookedTeamMembersTable.tsx b/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx similarity index 52% rename from packages/features/insights/components/MostBookedTeamMembersTable.tsx rename to packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx index e7fb459e75353c..f2009810f5b8df 100644 --- a/packages/features/insights/components/MostBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const MostBookedTeamMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const MostBookedTeamMembersTable = () => { return ( - + ); }; diff --git a/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx b/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx new file mode 100644 index 00000000000000..022802e2e6c3bc --- /dev/null +++ b/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; + +export const MostCancelledBookingsTables = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( + insightsBookingParams, + { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/PopularEventsTable.tsx b/packages/features/insights/components/booking/PopularEventsTable.tsx similarity index 65% rename from packages/features/insights/components/PopularEventsTable.tsx rename to packages/features/insights/components/booking/PopularEventsTable.tsx index 6c23dbf0415610..584513ff4e16b7 100644 --- a/packages/features/insights/components/PopularEventsTable.tsx +++ b/packages/features/insights/components/booking/PopularEventsTable.tsx @@ -1,31 +1,23 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard, ChartCardItem } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard, ChartCardItem } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; export const PopularEventsTable = () => { const { t } = useLocale(); - const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.popularEventTypes.useQuery( - { - startDate, - endDate, - teamId, - userId, - eventTypeId, - memberUserId, - isAll, + const { data, isSuccess, isPending } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); + }); if (isPending) return ; diff --git a/packages/features/insights/components/booking/RecentFeedbackTable.tsx b/packages/features/insights/components/booking/RecentFeedbackTable.tsx new file mode 100644 index 00000000000000..69d7fca3ba87e6 --- /dev/null +++ b/packages/features/insights/components/booking/RecentFeedbackTable.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { FeedbackTable } from "../FeedbackTable"; +import { LoadingInsight } from "../LoadingInsights"; + +export const RecentFeedbackTable = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + }); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/TimezoneBadge.tsx b/packages/features/insights/components/booking/TimezoneBadge.tsx similarity index 99% rename from packages/features/insights/components/TimezoneBadge.tsx rename to packages/features/insights/components/booking/TimezoneBadge.tsx index 94946756f5685e..2cdd93b36a52cc 100644 --- a/packages/features/insights/components/TimezoneBadge.tsx +++ b/packages/features/insights/components/booking/TimezoneBadge.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useMemo } from "react"; import { useDataTable } from "@calcom/features/data-table"; diff --git a/packages/features/insights/components/booking/index.ts b/packages/features/insights/components/booking/index.ts new file mode 100644 index 00000000000000..33009e88a78a2d --- /dev/null +++ b/packages/features/insights/components/booking/index.ts @@ -0,0 +1,13 @@ +export { AverageEventDurationChart } from "./AverageEventDurationChart"; +export { BookingKPICards } from "./BookingKPICards"; +export { EventTrendsChart } from "./EventTrendsChart"; +export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; +export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; +export { BookingsByHourChart, BookingsByHourChartContent } from "./BookingsByHourChart"; +export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; +export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; +export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; +export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; +export { PopularEventsTable } from "./PopularEventsTable"; +export { RecentFeedbackTable } from "./RecentFeedbackTable"; +export { TimezoneBadge } from "./TimezoneBadge"; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index 3d1724439b17f4..23a7b02fee64fd 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -1,19 +1,12 @@ -export { AverageEventDurationChart } from "./AverageEventDurationChart"; -export { BookingKPICards } from "./BookingKPICards"; -export { BookingsByHourChart } from "./BookingsByHourChart"; - -export { EventTrendsChart } from "./EventTrendsChart"; -export { FailedBookingsByField } from "./FailedBookingsByField"; -export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; -export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; -export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; -export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; -export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; -export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; -export { PopularEventsTable } from "./PopularEventsTable"; -export { RecentFeedbackTable } from "./RecentFeedbackTable"; -export { RoutedToPerPeriod } from "./RoutedToPerPeriod"; -export { RoutingFunnel } from "./RoutingFunnel"; -export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable"; -export { RoutingKPICards } from "./RoutingKPICards"; -export { TimezoneBadge } from "./TimezoneBadge"; +export { BookedByCell } from "./BookedByCell"; +export { BookingAtCell } from "./BookingAtCell"; +export { BookingStatusBadge } from "./BookingStatusBadge"; +export { CardInsights } from "./Card"; +export { CellWithOverflowX } from "./CellWithOverflowX"; +export { ChartCard } from "./ChartCard"; +export { FeedbackTable } from "./FeedbackTable"; +export { KPICard } from "./KPICard"; +export { LineChart } from "./LineChart"; +export { LoadingInsight } from "./LoadingInsights"; +export { ResponseValueCell } from "./ResponseValueCell"; +export { UserStatsTable } from "./UserStatsTable"; diff --git a/packages/features/insights/components/FailedBookingsByField.tsx b/packages/features/insights/components/routing/FailedBookingsByField.tsx similarity index 92% rename from packages/features/insights/components/FailedBookingsByField.tsx rename to packages/features/insights/components/routing/FailedBookingsByField.tsx index 27fd463775beaa..022d34b2c713c8 100644 --- a/packages/features/insights/components/FailedBookingsByField.tsx +++ b/packages/features/insights/components/routing/FailedBookingsByField.tsx @@ -1,12 +1,14 @@ +"use client"; + import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { ToggleGroup } from "@calcom/ui/components/form"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { BarList } from "./tremor/BarList"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { ChartCard } from "../ChartCard"; +import { BarList } from "../tremor/BarList"; interface FormCardProps { formName: string; diff --git a/packages/features/insights/components/RoutedToPerPeriod.tsx b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx similarity index 99% rename from packages/features/insights/components/RoutedToPerPeriod.tsx rename to packages/features/insights/components/routing/RoutedToPerPeriod.tsx index 793e50c3dd364b..a3ee01fa8ffba0 100644 --- a/packages/features/insights/components/RoutedToPerPeriod.tsx +++ b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { TFunction } from "i18next"; import { useQueryState } from "nuqs"; import { type ReactNode, useMemo, useRef, useState } from "react"; @@ -23,8 +25,8 @@ import { } from "@calcom/ui/components/table"; import { Tooltip } from "@calcom/ui/components/tooltip"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { ChartCard } from "../ChartCard"; interface DownloadButtonProps { teamId?: number; diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx similarity index 90% rename from packages/features/insights/components/RoutingFormResponsesTable.tsx rename to packages/features/insights/components/routing/RoutingFormResponsesTable.tsx index e8b69331d0e6d3..936493791df2e3 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx @@ -18,12 +18,12 @@ import { } from "@calcom/features/data-table"; import { trpc } from "@calcom/trpc"; -import { RoutingFormResponsesDownload } from "../filters/Download"; -import { OrgTeamsFilter } from "../filters/OrgTeamsFilter"; -import { useInsightsColumns } from "../hooks/useInsightsColumns"; -import { useInsightsFacetedUniqueValues } from "../hooks/useInsightsFacetedUniqueValues"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import type { RoutingFormTableRow } from "../lib/types"; +import { RoutingFormResponsesDownload } from "../../filters/Download"; +import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter"; +import { useInsightsColumns } from "../../hooks/useInsightsColumns"; +import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import type { RoutingFormTableRow } from "../../lib/types"; import { RoutingKPICards } from "./RoutingKPICards"; export type RoutingFormTableType = ReturnType>; diff --git a/packages/features/insights/components/RoutingFunnel.tsx b/packages/features/insights/components/routing/RoutingFunnel.tsx similarity index 96% rename from packages/features/insights/components/RoutingFunnel.tsx rename to packages/features/insights/components/routing/RoutingFunnel.tsx index 43a4bdeddf70a7..a6258be0a08fc2 100644 --- a/packages/features/insights/components/RoutingFunnel.tsx +++ b/packages/features/insights/components/routing/RoutingFunnel.tsx @@ -5,7 +5,7 @@ import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsigh import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { ChartCard } from "./ChartCard"; +import { ChartCard } from "../ChartCard"; import { RoutingFunnelContent, legend } from "./RoutingFunnelContent"; import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton"; diff --git a/packages/features/insights/components/RoutingFunnelContent.tsx b/packages/features/insights/components/routing/RoutingFunnelContent.tsx similarity index 100% rename from packages/features/insights/components/RoutingFunnelContent.tsx rename to packages/features/insights/components/routing/RoutingFunnelContent.tsx diff --git a/packages/features/insights/components/RoutingFunnelSkeleton.tsx b/packages/features/insights/components/routing/RoutingFunnelSkeleton.tsx similarity index 100% rename from packages/features/insights/components/RoutingFunnelSkeleton.tsx rename to packages/features/insights/components/routing/RoutingFunnelSkeleton.tsx diff --git a/packages/features/insights/components/RoutingKPICards.tsx b/packages/features/insights/components/routing/RoutingKPICards.tsx similarity index 93% rename from packages/features/insights/components/RoutingKPICards.tsx rename to packages/features/insights/components/routing/RoutingKPICards.tsx index a912dd3cb63fce..87f180ce75969d 100644 --- a/packages/features/insights/components/RoutingKPICards.tsx +++ b/packages/features/insights/components/routing/RoutingKPICards.tsx @@ -1,12 +1,14 @@ +"use client"; + import { Grid } from "@tremor/react"; import { Flex, Text, Metric } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { valueFormatter } from "../lib"; -import { CardInsights } from "./Card"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { valueFormatter } from "../../lib"; +import { CardInsights } from "../Card"; export const RoutingKPICards = () => { const { t } = useLocale(); diff --git a/packages/features/insights/components/routing/index.ts b/packages/features/insights/components/routing/index.ts new file mode 100644 index 00000000000000..cbb60157a6c9da --- /dev/null +++ b/packages/features/insights/components/routing/index.ts @@ -0,0 +1,7 @@ +export { FailedBookingsByField } from "./FailedBookingsByField"; +export { RoutedToPerPeriod } from "./RoutedToPerPeriod"; +export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable"; +export { RoutingFunnel } from "./RoutingFunnel"; +export { RoutingFunnelContent } from "./RoutingFunnelContent"; +export { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton"; +export { RoutingKPICards } from "./RoutingKPICards"; diff --git a/packages/features/insights/components/tremor/BarList.tsx b/packages/features/insights/components/tremor/BarList.tsx index ea76ba61f43a8b..c6b2d476ce9dd4 100644 --- a/packages/features/insights/components/tremor/BarList.tsx +++ b/packages/features/insights/components/tremor/BarList.tsx @@ -1,3 +1,5 @@ +"use client"; + // Tremor BarList [v0.1.1] import React from "react"; diff --git a/packages/features/insights/filters/Download/Download.tsx b/packages/features/insights/filters/Download/Download.tsx index 91b7ecd32dd9b0..3a40d7602dec55 100644 --- a/packages/features/insights/filters/Download/Download.tsx +++ b/packages/features/insights/filters/Download/Download.tsx @@ -1,10 +1,8 @@ import { useState } from "react"; import dayjs from "@calcom/dayjs"; -import { useDataTable } from "@calcom/features/data-table"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { trpc } from "@calcom/trpc"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; @@ -16,7 +14,7 @@ import { } from "@calcom/ui/components/dropdown"; import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; -import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number]; @@ -24,8 +22,8 @@ const BATCH_SIZE = 100; const Download = () => { const { t } = useLocale(); - const { timeZone } = useDataTable(); - const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); + const { startDate, endDate } = insightsBookingParams; const [isDownloading, setIsDownloading] = useState(false); const utils = trpc.useUtils(); @@ -37,13 +35,7 @@ const Download = () => { const fetchBatch = async (offset: number): Promise => { try { const result = await utils.viewer.insights.rawData.fetch({ - scope, - selectedTeamId, - startDate, - endDate, - timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, + ...insightsBookingParams, limit: BATCH_SIZE, offset, }); diff --git a/packages/features/insights/hooks/useInsightsBookingParameters.ts b/packages/features/insights/hooks/useInsightsBookingParameters.ts new file mode 100644 index 00000000000000..f3fd61a1b7b5fc --- /dev/null +++ b/packages/features/insights/hooks/useInsightsBookingParameters.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table"; +import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; +import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable"; +import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue"; +import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange"; +import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; + +import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; + +export function useInsightsBookingParameters() { + const { scope, selectedTeamId } = useInsightsOrgTeams(); + const { timeZone } = useDataTable(); + + const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined; + const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined; + const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data; + // TODO for future: this preserving local time & startOf & endOf should be handled + // from DateRangeFilter out of the box. + // When we do it, we also need to remove those timezone handling logic from the backend side at the same time. + const startDate = useChangeTimeZoneWithPreservedLocalTime( + useMemo(() => { + return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString()) + .startOf("day") + .toISOString(); + }, [createdAtRange?.startDate]) + ); + const endDate = useChangeTimeZoneWithPreservedLocalTime( + useMemo(() => { + return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()) + .endOf("day") + .toISOString(); + }, [createdAtRange?.endDate]) + ); + + return { + scope, + selectedTeamId, + startDate, + endDate, + timeZone: timeZone || CURRENT_TIMEZONE, + eventTypeId, + memberUserId, + }; +} diff --git a/packages/features/insights/hooks/useInsightsBookings.ts b/packages/features/insights/hooks/useInsightsBookings.ts index 42025670b0c47d..256bf12937b6ad 100644 --- a/packages/features/insights/hooks/useInsightsBookings.ts +++ b/packages/features/insights/hooks/useInsightsBookings.ts @@ -6,7 +6,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { HeaderRow, RoutingFormTableRow } from "../lib/types"; import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues"; -import { useInsightsParameters } from "./useInsightsParameters"; +import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; type DummyTableRow = { bookingUserId: RoutingFormTableRow["bookingUserId"]; @@ -18,7 +18,7 @@ const dummyHeaders: HeaderRow[] = []; export const useInsightsBookings = () => { const { t } = useLocale(); - const { isAll, teamId, userId } = useInsightsParameters(); + const { isAll, teamId, userId } = useInsightsOrgTeams(); const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers: dummyHeaders, diff --git a/packages/features/insights/server/__tests__/events.test.ts b/packages/features/insights/server/__tests__/events.test.ts index 2a49f02f9f1c09..c48df41ad828aa 100644 --- a/packages/features/insights/server/__tests__/events.test.ts +++ b/packages/features/insights/server/__tests__/events.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import dayjs from "@calcom/dayjs"; -import { EventsInsights } from "../events"; +import { getTimeView, getDateRanges, formatPeriod } from "../insightsDateUtils"; describe("EventsInsights", () => { describe("getDateRanges", () => { @@ -13,7 +13,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Beginning of May 1st UTC const endDate = "2025-05-03T23:59:59.999Z"; // End of May 3rd UTC - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -50,7 +50,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-25T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -99,7 +99,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-15T00:00:00.000Z"; const endDate = "2025-07-15T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -136,7 +136,7 @@ describe("EventsInsights", () => { const startDate = "2025-06-15T00:00:00.000Z"; const endDate = "2027-03-15T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -173,7 +173,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; const endDate = "2025-05-01T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -202,7 +202,7 @@ describe("EventsInsights", () => { const startDate = "2025-03-29T23:00:00.000Z"; // March 30th 00:00 Paris time const endDate = "2025-03-31T21:59:59.999Z"; // March 31st 23:59:59 Paris time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -237,7 +237,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-15T22:00:00.000Z"; // May 16th 00:00 Paris time const endDate = "2025-05-29T21:59:59.999Z"; // May 29th 23:59:59 Paris time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -274,7 +274,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-31T22:00:00.000Z"; // June 1st 00:00 Paris time const endDate = "2025-07-31T21:59:59.999Z"; // July 31st 23:59:59 Paris time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -309,7 +309,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-14T15:00:00.000Z"; // May 15th 00:00 Seoul time const endDate = "2025-05-16T14:59:59.999Z"; // May 16th 23:59:59 Seoul time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -340,7 +340,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-11T15:00:00.000Z"; // May 12th 00:00 Seoul time (Monday) const endDate = "2025-05-25T14:59:59.999Z"; // May 25th 23:59:59 Seoul time (Sunday) - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -377,7 +377,7 @@ describe("EventsInsights", () => { const startDate = "2025-04-30T15:00:00.000Z"; // May 1st 00:00 Seoul time const endDate = "2025-06-30T14:59:59.999Z"; // June 30th 23:59:59 Seoul time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -410,7 +410,7 @@ describe("EventsInsights", () => { const endDate = "2025-05-03T23:59:59.999Z"; const timeZone = "UTC"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -433,7 +433,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-14T23:59:59.999Z"; // Wednesday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -470,7 +470,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-14T23:59:59.999Z"; // Wednesday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -507,7 +507,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-14T23:59:59.999Z"; // Wednesday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -546,7 +546,7 @@ describe("EventsInsights", () => { describe("Day View", () => { describe("Beginning of data (wholeStart === start)", () => { it("should always show month for the first day when same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-15"), end: dayjs("2024-01-15"), timeView: "day", @@ -557,7 +557,7 @@ describe("EventsInsights", () => { }); it("should always show month for the first day when different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-15"), end: dayjs("2024-01-15"), timeView: "day", @@ -570,7 +570,7 @@ describe("EventsInsights", () => { describe("First day of month (start.date() === 1)", () => { it("should show month for 1st day of month when same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-02-01"), end: dayjs("2024-02-01"), timeView: "day", @@ -581,7 +581,7 @@ describe("EventsInsights", () => { }); it("should show month for 1st day of month when different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-02-01"), end: dayjs("2024-02-01"), timeView: "day", @@ -594,7 +594,7 @@ describe("EventsInsights", () => { describe("Regular days (not first day, not 1st of month)", () => { it("should omit month for regular days when same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-16"), end: dayjs("2024-01-16"), timeView: "day", @@ -605,7 +605,7 @@ describe("EventsInsights", () => { }); it("should omit month but show year for regular days when different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-16"), end: dayjs("2024-01-16"), timeView: "day", @@ -618,7 +618,7 @@ describe("EventsInsights", () => { describe("Edge cases", () => { it("should show month when start is both beginning of data AND 1st of month", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-01"), timeView: "day", @@ -630,7 +630,7 @@ describe("EventsInsights", () => { it("should show month for last day of month if it's 1st of month (edge case)", () => { // This tests the case where start.date() === 1 takes precedence - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-03-01"), end: dayjs("2024-03-01"), timeView: "day", @@ -641,7 +641,7 @@ describe("EventsInsights", () => { }); it("should omit month for end of month when not 1st and not beginning", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-31"), end: dayjs("2024-01-31"), timeView: "day", @@ -662,7 +662,7 @@ describe("EventsInsights", () => { for (let i = 0; i <= 5; i++) { const currentDay = wholeStart.add(i, "day"); results.push( - EventsInsights.formatPeriod({ + formatPeriod({ start: currentDay, end: currentDay, timeView: "day", @@ -684,7 +684,7 @@ describe("EventsInsights", () => { let currentDay = wholeStart; while (currentDay.isBefore(wholeEnd) || currentDay.isSame(wholeEnd)) { results.push( - EventsInsights.formatPeriod({ + formatPeriod({ start: currentDay, end: currentDay, timeView: "day", @@ -707,7 +707,7 @@ describe("EventsInsights", () => { let currentDay = wholeStart; while (currentDay.isBefore(wholeEnd) || currentDay.isSame(wholeEnd)) { results.push( - EventsInsights.formatPeriod({ + formatPeriod({ start: currentDay, end: currentDay, timeView: "day", @@ -722,7 +722,7 @@ describe("EventsInsights", () => { }); it("should format a single day range correctly", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-15"), end: dayjs("2024-01-15"), timeView: "day", @@ -738,7 +738,7 @@ describe("EventsInsights", () => { describe("Week View", () => { describe("Same month", () => { it("should format dates without year when wholeStart and wholeEnd are same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-07"), timeView: "week", @@ -749,7 +749,7 @@ describe("EventsInsights", () => { }); it("should format dates with year when wholeStart and wholeEnd are different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-07"), timeView: "week", @@ -762,7 +762,7 @@ describe("EventsInsights", () => { describe("Different months", () => { it("should format dates without year when wholeStart and wholeEnd are same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-29"), end: dayjs("2024-02-04"), timeView: "week", @@ -773,7 +773,7 @@ describe("EventsInsights", () => { }); it("should format dates with year when wholeStart and wholeEnd are different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-29"), end: dayjs("2024-02-04"), timeView: "week", @@ -786,7 +786,7 @@ describe("EventsInsights", () => { describe("Different years", () => { it("should format dates with respective years when start and end span different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2023-12-31"), end: dayjs("2024-01-06"), timeView: "week", @@ -800,7 +800,7 @@ describe("EventsInsights", () => { describe("Month View", () => { it("should format month without year when wholeStart and wholeEnd are same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-31"), timeView: "month", @@ -811,7 +811,7 @@ describe("EventsInsights", () => { }); it("should format month with year when wholeStart and wholeEnd are different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-31"), timeView: "month", @@ -824,7 +824,7 @@ describe("EventsInsights", () => { describe("Year View", () => { it("should format year regardless of wholeStart and wholeEnd values", () => { - const resultWithSameYear = EventsInsights.formatPeriod({ + const resultWithSameYear = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-12-31"), timeView: "year", @@ -833,7 +833,7 @@ describe("EventsInsights", () => { }); expect(resultWithSameYear).toBe("2024"); - const resultWithDifferentYears = EventsInsights.formatPeriod({ + const resultWithDifferentYears = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-12-31"), timeView: "year", @@ -846,7 +846,7 @@ describe("EventsInsights", () => { describe("Invalid View", () => { it("should return empty string for invalid timeView", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-01"), timeView: "invalid" as any, @@ -860,27 +860,27 @@ describe("EventsInsights", () => { describe("getTimeView", () => { it("should return year for ranges over 365 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2025-02-01T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2025-02-01T00:00:00.000Z"); expect(result).toBe("year"); }); it("should return month for ranges between 90 and 365 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-05-01T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-05-01T00:00:00.000Z"); expect(result).toBe("month"); }); it("should return week for ranges between 14 and 90 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-02-01T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-02-01T00:00:00.000Z"); expect(result).toBe("week"); }); it("should return day for ranges under 14 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-01-10T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-01-10T00:00:00.000Z"); expect(result).toBe("day"); }); it("should handle same day range", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-01-01T23:59:59.999Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-01-01T23:59:59.999Z"); expect(result).toBe("day"); }); }); @@ -893,7 +893,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; const endDate = "2024-01-19T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -919,7 +919,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-30T00:00:00.000Z"; const endDate = "2024-02-02T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -947,7 +947,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; // Monday const endDate = "2024-01-28T23:59:59.999Z"; // Sunday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -973,7 +973,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-29T00:00:00.000Z"; // Monday const endDate = "2024-02-11T23:59:59.999Z"; // Sunday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -1001,7 +1001,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; const endDate = "2024-03-15T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -1030,7 +1030,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; const endDate = "2024-01-21T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -1063,7 +1063,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-30T00:00:00.000Z"; const endDate = "2024-02-05T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 1e045176de1f92..99e0df2a3d299c 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -1,100 +1,7 @@ -import dayjs from "@calcom/dayjs"; import { readonlyPrisma as prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; -type TimeViewType = "week" | "month" | "year" | "day"; - -type StatusAggregate = { - completed: number; - rescheduled: number; - cancelled: number; - noShowHost: number; - noShowGuests: number; - _all: number; - uncompleted: number; -}; - -type AggregateResult = { - [date: string]: StatusAggregate; -}; - -// Recursive function to convert a JSON condition object into SQL -// Helper type guard function to check if value has 'in' property -function isInCondition(value: any): value is { in: any[] } { - return typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in); -} - -// Helper type guard function to check if value has 'gte' property -function isGteCondition(value: any): value is { gte: any } { - return typeof value === "object" && value !== null && "gte" in value; -} - -// Helper type guard function to check if value has 'lte' property -function isLteCondition(value: any): value is { lte: any } { - return typeof value === "object" && value !== null && "lte" in value; -} - -function buildSqlCondition(condition: any): string { - if (Array.isArray(condition.OR)) { - return `(${condition.OR.map(buildSqlCondition).join(" OR ")})`; - } else if (Array.isArray(condition.AND)) { - return `(${condition.AND.map(buildSqlCondition).join(" AND ")})`; - } else { - const clauses: string[] = []; - for (const [key, value] of Object.entries(condition)) { - if (isInCondition(value)) { - const valuesList = value.in.map((v) => `'${v}'`).join(", "); - clauses.push(`"${key}" IN (${valuesList})`); - } else if (isGteCondition(value)) { - clauses.push(`"${key}" >= '${value.gte}'`); - } else if (isLteCondition(value)) { - clauses.push(`"${key}" <= '${value.lte}'`); - } else { - const formattedValue = typeof value === "string" ? `'${value}'` : value; - clauses.push(`"${key}" = ${formattedValue}`); - } - } - return clauses.join(" AND "); - } -} - -export interface DateRange { - startDate: string; - endDate: string; - formattedDate: string; - formattedDateFull: string; -} - -export interface GetDateRangesParams { - startDate: string; - endDate: string; - timeZone: string; - timeView: "day" | "week" | "month" | "year"; - weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | string; -} - -class EventsInsights { - static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => { - const bookings = await prisma.bookingTimeStatusDenormalized.findMany({ - where, - select: { - id: true, - }, - }); - - const { _count: totalNoShowGuests } = await prisma.attendee.aggregate({ - where: { - bookingId: { - in: bookings.map((booking) => booking.id), - }, - noShow: true, - }, - _count: true, - }); - - return totalNoShowGuests; - }; - +export class EventsInsights { static countGroupedByStatus = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => { const data = await prisma.bookingTimeStatusDenormalized.groupBy({ where, @@ -125,299 +32,4 @@ class EventsInsights { } ); }; - - static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => { - return await prisma.bookingTimeStatusDenormalized.aggregate({ - _avg: { - rating: true, - }, - where: { - ...whereConditional, - rating: { - not: null, // Exclude null ratings - }, - }, - }); - }; - - static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => { - const result = await prisma.bookingTimeStatusDenormalized.findMany({ - where: { - ...whereConditional, - rating: { - not: null, - }, - }, - select: { rating: true }, - }); - - const totalResponses = result.length; - const satisfactoryResponses = result.filter((item) => item.rating && item.rating > 3).length; - const csat = totalResponses > 0 ? (satisfactoryResponses / totalResponses) * 100 : 0; - - return csat; - }; - - static getTimeView = (startDate: string, endDate: string) => { - const diff = dayjs(endDate).diff(dayjs(startDate), "day"); - if (diff > 365) { - return "year"; - } else if (diff > 90) { - return "month"; - } else if (diff > 30) { - return "week"; - } else { - return "day"; - } - }; - - static getPercentage = (actualMetric: number, previousMetric: number) => { - const differenceActualVsPrevious = actualMetric - previousMetric; - if (differenceActualVsPrevious === 0) { - return 0; - } - const result = (differenceActualVsPrevious * 100) / previousMetric; - - if (isNaN(result) || !isFinite(result)) { - return 0; - } - - return result; - }; - - static getDateRanges({ - startDate: _startDate, - endDate: _endDate, - timeZone, - timeView, - weekStart, - }: GetDateRangesParams): DateRange[] { - if (!["day", "week", "month", "year"].includes(timeView)) { - return []; - } - - const startDate = dayjs(_startDate).tz(timeZone); - const endDate = dayjs(_endDate).tz(timeZone); - const ranges: DateRange[] = []; - let currentStartDate = startDate; - - while (currentStartDate.isBefore(endDate)) { - let currentEndDate = currentStartDate.endOf(timeView).tz(timeZone); - - // Adjust week boundaries based on weekStart parameter - if (timeView === "week") { - const weekStartNum = - { - Sunday: 0, - Monday: 1, - Tuesday: 2, - Wednesday: 3, - Thursday: 4, - Friday: 5, - Saturday: 6, - }[weekStart] ?? 0; - - currentEndDate = currentEndDate.add(weekStartNum, "day"); - if (currentEndDate.subtract(7, "day").isAfter(currentStartDate)) { - currentEndDate = currentEndDate.subtract(7, "day"); - } - } - - if (currentEndDate.isAfter(endDate)) { - currentEndDate = endDate; - ranges.push({ - startDate: currentStartDate.toISOString(), - endDate: currentEndDate.toISOString(), - formattedDate: this.formatPeriod({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - formattedDateFull: this.formatPeriodFull({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - }); - break; - } - - ranges.push({ - startDate: currentStartDate.toISOString(), - endDate: currentEndDate.toISOString(), - formattedDate: this.formatPeriod({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - formattedDateFull: this.formatPeriodFull({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - }); - - currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone); - } - - return ranges; - } - - static formatPeriod({ - start, - end, - timeView, - wholeStart, - wholeEnd, - }: { - start: dayjs.Dayjs; - end: dayjs.Dayjs; - timeView: TimeViewType; - wholeStart: dayjs.Dayjs; - wholeEnd: dayjs.Dayjs; - }): string { - const omitYear = wholeStart.year() === wholeEnd.year(); - - switch (timeView) { - case "day": - const shouldShowMonth = wholeStart.isSame(start, "day") || start.date() === 1; - - if (shouldShowMonth) { - return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); - } else { - return omitYear ? start.format("D") : start.format("D, YYYY"); - } - case "week": - const startFormat = "MMM D"; - let endFormat = "MMM D"; - if (start.format("MMM") === end.format("MMM")) { - endFormat = "D"; - } - - if (start.format("YYYY") !== end.format("YYYY")) { - return `${start.format(`${startFormat} , YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; - } - - if (omitYear) { - return `${start.format(startFormat)} - ${end.format(endFormat)}`; - } else { - return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; - } - case "month": - return omitYear ? start.format("MMM") : start.format("MMM YYYY"); - case "year": - return start.format("YYYY"); - default: - return ""; - } - } - - static formatPeriodFull({ - start, - end, - timeView, - wholeStart, - wholeEnd, - }: { - start: dayjs.Dayjs; - end: dayjs.Dayjs; - timeView: TimeViewType; - wholeStart: dayjs.Dayjs; - wholeEnd: dayjs.Dayjs; - }): string { - const omitYear = wholeStart.year() === wholeEnd.year(); - - switch (timeView) { - case "day": - return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); - case "week": - const startFormat = "MMM D"; - const endFormat = "MMM D"; - - if (start.format("YYYY") !== end.format("YYYY")) { - return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; - } - - if (omitYear) { - return `${start.format(startFormat)} - ${end.format(endFormat)}`; - } else { - return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; - } - case "month": - return omitYear ? start.format("MMM") : start.format("MMM YYYY"); - case "year": - return start.format("YYYY"); - default: - return ""; - } - } - - static userIsOwnerAdminOfTeam = async ({ - sessionUserId, - teamId, - }: { - sessionUserId: number; - teamId: number; - }) => { - const isOwnerAdminOfTeam = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: sessionUserId, - teamId, - }, - accepted: true, - role: { - in: ["OWNER", "ADMIN"], - }, - }, - }); - - return !!isOwnerAdminOfTeam; - }; - - static userIsOwnerAdminOfParentTeam = async ({ - sessionUserId, - teamId, - }: { - sessionUserId: number; - teamId: number; - }) => { - const team = await prisma.team.findFirst({ - select: { - parentId: true, - }, - where: { - id: teamId, - }, - }); - - if (!team || team.parentId === null) { - return false; - } - - const isOwnerAdminOfParentTeam = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: sessionUserId, - teamId: team.parentId, - }, - accepted: true, - role: { - in: ["OWNER", "ADMIN"], - }, - }, - }); - - return !!isOwnerAdminOfParentTeam; - }; } - -export { EventsInsights }; diff --git a/packages/features/insights/server/insightsDateUtils.ts b/packages/features/insights/server/insightsDateUtils.ts new file mode 100644 index 00000000000000..d9498cba839ecf --- /dev/null +++ b/packages/features/insights/server/insightsDateUtils.ts @@ -0,0 +1,214 @@ +import dayjs from "@calcom/dayjs"; + +type TimeViewType = "week" | "month" | "year" | "day"; + +export interface DateRange { + startDate: string; + endDate: string; + formattedDate: string; + formattedDateFull: string; +} + +export interface GetDateRangesParams { + startDate: string; + endDate: string; + timeZone: string; + timeView: "day" | "week" | "month" | "year"; + weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | string; +} + +export const getTimeView = (startDate: string, endDate: string) => { + const diff = dayjs(endDate).diff(dayjs(startDate), "day"); + if (diff > 365) { + return "year"; + } else if (diff > 90) { + return "month"; + } else if (diff > 30) { + return "week"; + } else { + return "day"; + } +}; + +export const getDateRanges = ({ + startDate: _startDate, + endDate: _endDate, + timeZone, + timeView, + weekStart, +}: GetDateRangesParams): DateRange[] => { + if (!["day", "week", "month", "year"].includes(timeView)) { + return []; + } + + const startDate = dayjs(_startDate).tz(timeZone); + const endDate = dayjs(_endDate).tz(timeZone); + const ranges: DateRange[] = []; + let currentStartDate = startDate; + + while (currentStartDate.isBefore(endDate)) { + let currentEndDate = currentStartDate.endOf(timeView).tz(timeZone); + + // Adjust week boundaries based on weekStart parameter + if (timeView === "week") { + const weekStartNum = + { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, + }[weekStart] ?? 0; + + currentEndDate = currentEndDate.add(weekStartNum, "day"); + if (currentEndDate.subtract(7, "day").isAfter(currentStartDate)) { + currentEndDate = currentEndDate.subtract(7, "day"); + } + } + + if (currentEndDate.isAfter(endDate)) { + currentEndDate = endDate; + ranges.push({ + startDate: currentStartDate.toISOString(), + endDate: currentEndDate.toISOString(), + formattedDate: formatPeriod({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + formattedDateFull: formatPeriodFull({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + }); + break; + } + + ranges.push({ + startDate: currentStartDate.toISOString(), + endDate: currentEndDate.toISOString(), + formattedDate: formatPeriod({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + formattedDateFull: formatPeriodFull({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + }); + + currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone); + } + + return ranges; +}; + +export const formatPeriod = ({ + start, + end, + timeView, + wholeStart, + wholeEnd, +}: { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + timeView: TimeViewType; + wholeStart: dayjs.Dayjs; + wholeEnd: dayjs.Dayjs; +}): string => { + const omitYear = wholeStart.year() === wholeEnd.year(); + + switch (timeView) { + case "day": { + const shouldShowMonth = wholeStart.isSame(start, "day") || start.date() === 1; + + if (shouldShowMonth) { + return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); + } else { + return omitYear ? start.format("D") : start.format("D, YYYY"); + } + } + case "week": { + const startFormat = "MMM D"; + let endFormat = "MMM D"; + if (start.format("MMM") === end.format("MMM")) { + endFormat = "D"; + } + + if (start.format("YYYY") !== end.format("YYYY")) { + return `${start.format(`${startFormat} , YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; + } + + if (omitYear) { + return `${start.format(startFormat)} - ${end.format(endFormat)}`; + } else { + return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; + } + } + case "month": { + return omitYear ? start.format("MMM") : start.format("MMM YYYY"); + } + case "year": { + return start.format("YYYY"); + } + default: + return ""; + } +}; + +export const formatPeriodFull = ({ + start, + end, + timeView, + wholeStart, + wholeEnd, +}: { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + timeView: TimeViewType; + wholeStart: dayjs.Dayjs; + wholeEnd: dayjs.Dayjs; +}): string => { + const omitYear = wholeStart.year() === wholeEnd.year(); + + switch (timeView) { + case "day": { + return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); + } + case "week": { + const startFormat = "MMM D"; + const endFormat = "MMM D"; + + if (start.format("YYYY") !== end.format("YYYY")) { + return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; + } + + if (omitYear) { + return `${start.format(startFormat)} - ${end.format(endFormat)}`; + } else { + return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; + } + } + case "month": { + return omitYear ? start.format("MMM") : start.format("MMM YYYY"); + } + case "year": { + return start.format("YYYY"); + } + default: + return ""; + } +}; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index de7f4bf72e7e7b..cd980ec852f4e4 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1,5 +1,4 @@ import type { Prisma } from "@prisma/client"; -import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; @@ -19,7 +18,7 @@ import { router } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; -import { EventsInsights, type GetDateRangesParams } from "./events"; +import { getTimeView, getDateRanges, type GetDateRangesParams } from "./insightsDateUtils"; import { RoutingEventsInsights } from "./routing-events"; import { VirtualQueuesInsights } from "./virtual-queues"; @@ -262,7 +261,7 @@ const userSelect = { avatarUrl: true, }; -const emptyResponseEventsByStatus = { +const emptyResponseBookingKPIStats = { empty: true, created: { count: 0, @@ -343,151 +342,114 @@ function createInsightsBookingService( }); } export const insightsRouter = router({ - eventsByStatus: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input; - if (userId && userId !== ctx.user.id) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - const { whereCondition: whereConditional } = r; + bookingKPIStats: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const currentPeriodService = createInsightsBookingService(ctx, input); - const baseWhereCondition = { - ...whereConditional, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; + // Get current period stats + const currentStats = await currentPeriodService.getBookingStats(); - const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day"); + // Calculate previous period dates and create service for previous period + const previousPeriodDates = currentPeriodService.calculatePreviousPeriodDates(); + const previousPeriodService = createInsightsBookingService(ctx, { + ...input, + startDate: previousPeriodDates.startDate, + endDate: previousPeriodDates.endDate, + }); - const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day"); - const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day"); + // Get previous period stats + const previousStats = await previousPeriodService.getBookingStats(); - const lastPeriodBaseCondition = { - ...whereConditional, - createdAt: { - gte: lastPeriodStartDate.toDate(), - lte: lastPeriodEndDate.toDate(), - }, - }; + // Helper function to calculate percentage change + const getPercentage = (current: number, previous: number): number => { + if (previous === 0) return current > 0 ? 100 : 0; + return ((current - previous) / previous) * 100; + }; - const [ - countGroupedByStatus, - totalRatingsAggregate, - totalCSAT, - totalNoShowGuests, - lastPeriodCountGroupedByStatus, - lastPeriodTotalRatingsAggregate, - lastPeriodTotalCSAT, - lastPeriodTotalNoShowGuests, - ] = await Promise.all([ - EventsInsights.countGroupedByStatus(baseWhereCondition), - EventsInsights.getAverageRating(baseWhereCondition), - EventsInsights.getTotalCSAT(baseWhereCondition), - EventsInsights.getTotalNoShowGuests(baseWhereCondition), - EventsInsights.countGroupedByStatus(lastPeriodBaseCondition), - EventsInsights.getAverageRating(lastPeriodBaseCondition), - EventsInsights.getTotalCSAT(lastPeriodBaseCondition), - EventsInsights.getTotalNoShowGuests(lastPeriodBaseCondition), - ]); - - const baseBookingsCount = countGroupedByStatus["_all"]; - const totalCompleted = countGroupedByStatus["completed"]; - const totalRescheduled = countGroupedByStatus["rescheduled"]; - const totalCancelled = countGroupedByStatus["cancelled"]; - const totalNoShow = countGroupedByStatus["noShowHost"]; - - const averageRating = totalRatingsAggregate._avg.rating - ? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1)) - : 0; - - const lastPeriodBaseBookingsCount = lastPeriodCountGroupedByStatus["_all"]; - const lastPeriodTotalRescheduled = lastPeriodCountGroupedByStatus["rescheduled"]; - const lastPeriodTotalCancelled = lastPeriodCountGroupedByStatus["cancelled"]; - const lastPeriodTotalNoShow = lastPeriodCountGroupedByStatus["noShowHost"]; - - const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating - ? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1)) - : 0; - - const result = { - empty: false, - created: { - count: baseBookingsCount, - deltaPrevious: EventsInsights.getPercentage(baseBookingsCount, lastPeriodBaseBookingsCount), - }, - completed: { - count: totalCompleted, - deltaPrevious: EventsInsights.getPercentage( - baseBookingsCount - totalCancelled - totalRescheduled, - lastPeriodBaseBookingsCount - lastPeriodTotalCancelled - lastPeriodTotalRescheduled - ), - }, - rescheduled: { - count: totalRescheduled, - deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled), - }, - cancelled: { - count: totalCancelled, - deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled), - }, - no_show: { - count: totalNoShow, - deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow), - }, - no_show_guest: { - count: totalNoShowGuests, - deltaPrevious: EventsInsights.getPercentage(totalNoShowGuests, lastPeriodTotalNoShowGuests), - }, - rating: { - count: averageRating, - deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating), - }, - csat: { - count: totalCSAT, - deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT), - }, - previousRange: { - startDate: lastPeriodStartDate.format("YYYY-MM-DD"), - endDate: lastPeriodEndDate.format("YYYY-MM-DD"), - }, - }; - if ( - result.created.count === 0 && - result.completed.count === 0 && - result.rescheduled.count === 0 && - result.cancelled.count === 0 && - result.no_show.count === 0 && - result.no_show_guest.count === 0 && - result.rating.count === 0 - ) { - return emptyResponseEventsByStatus; - } + // Calculate percentages and CSAT + const currentCSAT = + currentStats.total_ratings > 0 + ? (currentStats.ratings_above_3 / currentStats.total_ratings) * 100 + : 0; + const previousCSAT = + previousStats.total_ratings > 0 + ? (previousStats.ratings_above_3 / previousStats.total_ratings) * 100 + : 0; + + const currentRating = currentStats.avg_rating ? parseFloat(currentStats.avg_rating.toFixed(1)) : 0; + const previousRating = previousStats.avg_rating ? parseFloat(previousStats.avg_rating.toFixed(1)) : 0; + + // Check if all metrics are zero for empty state + const isEmpty = + currentStats.total_bookings === 0 && + currentStats.completed_bookings === 0 && + currentStats.rescheduled_bookings === 0 && + currentStats.cancelled_bookings === 0 && + currentStats.no_show_host_bookings === 0 && + currentStats.no_show_guests === 0 && + currentRating === 0; + + if (isEmpty) { + return emptyResponseBookingKPIStats; + } - return result; - }), + return { + empty: false, + created: { + count: currentStats.total_bookings, + deltaPrevious: getPercentage(currentStats.total_bookings, previousStats.total_bookings), + }, + completed: { + count: currentStats.completed_bookings, + deltaPrevious: getPercentage( + currentStats.total_bookings - currentStats.cancelled_bookings - currentStats.rescheduled_bookings, + previousStats.total_bookings - + previousStats.cancelled_bookings - + previousStats.rescheduled_bookings + ), + }, + rescheduled: { + count: currentStats.rescheduled_bookings, + deltaPrevious: getPercentage(currentStats.rescheduled_bookings, previousStats.rescheduled_bookings), + }, + cancelled: { + count: currentStats.cancelled_bookings, + deltaPrevious: getPercentage(currentStats.cancelled_bookings, previousStats.cancelled_bookings), + }, + no_show: { + count: currentStats.no_show_host_bookings, + deltaPrevious: getPercentage( + currentStats.no_show_host_bookings, + previousStats.no_show_host_bookings + ), + }, + no_show_guest: { + count: currentStats.no_show_guests, + deltaPrevious: getPercentage(currentStats.no_show_guests, previousStats.no_show_guests), + }, + rating: { + count: currentRating, + deltaPrevious: getPercentage(currentRating, previousRating), + }, + csat: { + count: currentCSAT, + deltaPrevious: getPercentage(currentCSAT, previousCSAT), + }, + previousRange: { + startDate: previousPeriodDates.formattedStartDate, + endDate: previousPeriodDates.formattedEndDate, + }, + }; + }), eventTrends: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { const { startDate, endDate, timeZone } = input; // Calculate timeView and dateRanges - const timeView = EventsInsights.getTimeView(startDate, endDate); - const dateRanges = EventsInsights.getDateRanges({ + const timeView = getTimeView(startDate, endDate); + const dateRanges = getDateRanges({ startDate, endDate, timeView, @@ -505,135 +467,17 @@ export const insightsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), - popularEventTypes: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input; - - const user = ctx.user; - - if (userId && user?.id !== userId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (!teamId && !userId) { - return []; - } - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; - - const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["eventTypeId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "desc", - }, - }, - take: 10, - }); - - const eventTypeIds = bookingsFromSelected - .filter((booking) => typeof booking.eventTypeId === "number") - .map((booking) => booking.eventTypeId); - - const eventTypeWhereConditional: Prisma.EventTypeWhereInput = { - id: { - in: eventTypeIds as number[], - }, - }; - - const eventTypesFrom = await ctx.insightsDb.eventType.findMany({ - select: { - id: true, - title: true, - teamId: true, - userId: true, - slug: true, - users: { - select: { - username: true, - }, - }, - team: { - select: { - slug: true, - }, - }, - }, - where: eventTypeWhereConditional, - }); - - const eventTypeHashMap: Map< - number, - Prisma.EventTypeGetPayload<{ - select: { - id: true; - title: true; - teamId: true; - userId: true; - slug: true; - users: { - select: { - username: true; - }; - }; - team: { - select: { - slug: true; - }; - }; - }; - }> - > = new Map(); - eventTypesFrom.forEach((eventType) => { - eventTypeHashMap.set(eventType.id, eventType); - }); - - const result = bookingsFromSelected.map((booking) => { - const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0); - if (!eventTypeSelected) { - return {}; - } + popularEvents: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - let eventSlug = ""; - if (eventTypeSelected.userId) { - eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`; - } - if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) { - eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`; + try { + return await insightsBookingService.getPopularEventsStats(); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - return { - eventTypeId: booking.eventTypeId, - eventTypeName: eventSlug, - count: booking._count.id, - }; - }); - - return result; - }), + }), averageEventDuration: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { @@ -642,8 +486,8 @@ export const insightsRouter = router({ const insightsBookingService = createInsightsBookingService(ctx, input); try { - const timeView = EventsInsights.getTimeView(startDate, endDate); - const dateRanges = EventsInsights.getDateRanges({ + const timeView = getTimeView(startDate, endDate); + const dateRanges = getDateRanges({ startDate, endDate, timeView, @@ -691,247 +535,37 @@ export const insightsRouter = router({ } }), membersWithMostCancelledBookings: userBelongsToTeamProcedure - .input(rawDataInputSchema) - .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - status: "CANCELLED", - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "desc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - if (userIds.length === 0) { - return []; + try { + return await insightsBookingService.getMembersStatsWithCount("cancelled", "DESC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => { - return { - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - }; - }); - - return result; }), membersWithMostBookings: userBelongsToTeamProcedure - .input(rawDataInputSchema) - .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "desc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - if (userIds.length === 0) { - return []; + try { + return await insightsBookingService.getMembersStatsWithCount("all", "DESC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => { - return { - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - }; - }); - - return result; }), membersWithLeastBookings: userBelongsToTeamProcedure - .input(rawDataInputSchema) - .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "asc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - if (userIds.length === 0) { - return []; + try { + return await insightsBookingService.getMembersStatsWithCount("all", "ASC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - })); - - return result; }), teamListForUser: authedProcedure.query(async ({ ctx }) => { const user = ctx.user; @@ -1112,354 +746,34 @@ export const insightsRouter = router({ return eventTypeList; }), - recentRatings: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - ratingFeedback: { not: null }, - }; - - if (isAll && user.isOwnerAdminOfParentTeam) { - delete bookingWhere.teamId; - const teamsFromOrg = await ctx.insightsDb.team.findMany({ - where: { - parentId: user?.organizationId, - }, - select: { - id: true, - }, - }); - const usersFromTeam = await ctx.insightsDb.membership.findMany({ - where: { - teamId: { - in: teamsFromOrg.map((t) => t.id), - }, - accepted: true, - }, - select: { - userId: true, - }, - }); - - bookingWhere["OR"] = [ - ...(bookingWhere.OR || []), - { - teamId: { - in: teamsFromOrg.map((t) => t.id), - }, - isTeamBooking: true, - }, - { - userId: { - in: usersFromTeam.map((u) => u.userId), - }, - isTeamBooking: false, - }, - ]; - } - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({ - where: bookingWhere, - orderBy: { - endTime: "desc", - }, - select: { - userId: true, - rating: true, - ratingFeedback: true, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (!!booking.userId && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - rating: booking.rating, - feedback: booking.ratingFeedback, - })); - - return result; - }), - membersWithMostNoShow: userBelongsToTeamProcedure - .input(rawDataInputSchema) + recentRatings: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - noShowHost: true, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "asc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); + const insightsBookingService = createInsightsBookingService(ctx, input); + return await insightsBookingService.getRecentRatingsStats(); + }), + membersWithMostNoShow: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - if (userIds.length === 0) { - return []; + try { + return await insightsBookingService.getMembersStatsWithCount("noShow", "DESC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - })); - - return result; }), membersWithHighestRatings: userBelongsToTeamProcedure - .input(rawDataInputSchema) + .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - rating: { not: null }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _avg: { - rating: true, - }, - orderBy: { - _avg: { - rating: "desc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - averageRating: booking._avg.rating, - })); - - return result; + const insightsBookingService = createInsightsBookingService(ctx, input); + return await insightsBookingService.getMembersRatingStats("DESC"); }), membersWithLowestRatings: userBelongsToTeamProcedure - .input(rawDataInputSchema) + .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - rating: { not: null }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _avg: { - rating: true, - }, - orderBy: { - _avg: { - rating: "asc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - averageRating: booking._avg.rating, - })); - - return result; + const insightsBookingService = createInsightsBookingService(ctx, input); + return await insightsBookingService.getMembersRatingStats("ASC"); }), rawData: userBelongsToTeamProcedure .input( @@ -1680,8 +994,8 @@ export const insightsRouter = router({ getRoutingFunnelData: userBelongsToTeamProcedure .input(routingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const timeView = EventsInsights.getTimeView(input.startDate, input.endDate); - const dateRanges = EventsInsights.getDateRanges({ + const timeView = getTimeView(input.startDate, input.endDate); + const dateRanges = getDateRanges({ startDate: input.startDate, endDate: input.endDate, timeZone: ctx.user.timeZone, diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 689364d9e5ddb5..79b9169b9b77dd 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -14,6 +14,7 @@ export enum CrudAction { Read = "read", Update = "update", Delete = "delete", + Manage = "manage", } export enum CustomAction { @@ -138,6 +139,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_roles", }, + [CrudAction.Manage]: { + description: "Manage roles on all sub-teams", + category: "role", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_roles", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.EventType]: { _resource: { @@ -167,6 +175,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_event_types", }, + [CrudAction.Manage]: { + description: "Manage event types", + category: "event", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_event_types", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.Team]: { _resource: { @@ -215,6 +230,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_change_member_role", descriptionI18nKey: "pbac_desc_change_team_member_role", }, + [CrudAction.Manage]: { + description: "Manage team members", + category: "team", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_team_members", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.Organization]: { _resource: { @@ -313,6 +335,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_bookings", }, + [CrudAction.Manage]: { + description: "Manage bookings", + category: "booking", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_bookings", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.Insights]: { _resource: { diff --git a/packages/features/pbac/services/__tests__/permission-check.service.test.ts b/packages/features/pbac/services/__tests__/permission-check.service.test.ts index c415a8dddd01e5..96e469ecf2373a 100644 --- a/packages/features/pbac/services/__tests__/permission-check.service.test.ts +++ b/packages/features/pbac/services/__tests__/permission-check.service.test.ts @@ -267,15 +267,6 @@ describe("PermissionCheckService", () => { (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); - mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ - id: membership.id, - teamId: membership.teamId, - userId: membership.userId, - customRoleId: membership.customRoleId, - team: { parentId: null }, - }); - mockRepository.getOrgMembership.mockResolvedValueOnce(null); - mockRepository.checkRolePermissions.mockResolvedValueOnce(false); const result = await service.checkPermissions({ userId: 1, @@ -285,7 +276,8 @@ describe("PermissionCheckService", () => { }); expect(result).toBe(false); - expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", []); + // Should not call repository methods for empty permissions array (security measure) + expect(mockRepository.checkRolePermissions).not.toHaveBeenCalled(); }); }); @@ -632,5 +624,252 @@ describe("PermissionCheckService", () => { Resource.EventType ); }); + + it("should expand permissions when user has manage permission", async () => { + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({ + id: 1, + teamId: 1, + userId: 1, + customRoleId: "admin_role", + team: { parentId: null }, + }); + + // User has manage permission + mockRepository.getResourcePermissionsByRoleId.mockResolvedValueOnce(["manage", "read"]); + + const result = await service.getResourcePermissions({ + userId: 1, + teamId: 1, + resource: Resource.EventType, + }); + + // Should include all possible actions for eventType resource + expect(result).toContain("eventType.manage"); + expect(result).toContain("eventType.create"); + expect(result).toContain("eventType.read"); + expect(result).toContain("eventType.update"); + expect(result).toContain("eventType.delete"); + expect(result.length).toBeGreaterThan(2); // More than just manage and read + }); + + it("should expand permissions when user has manage permission at org level", async () => { + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({ + id: 1, + teamId: 1, + userId: 1, + customRoleId: "team_role", + team: { parentId: 2 }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce({ + id: 2, + teamId: 2, + userId: 1, + customRoleId: "admin_role", + }); + + // Team has basic permissions, org has manage + mockRepository.getResourcePermissionsByRoleId + .mockResolvedValueOnce(["read"]) // team permissions + .mockResolvedValueOnce(["manage"]); // org permissions + + const result = await service.getResourcePermissions({ + userId: 1, + teamId: 1, + resource: Resource.Role, + }); + + // Should include all possible actions for role resource due to manage permission + expect(result).toContain("role.manage"); + expect(result).toContain("role.create"); + expect(result).toContain("role.read"); + expect(result).toContain("role.update"); + expect(result).toContain("role.delete"); + }); + }); + + describe("hasPermission with manage permissions", () => { + it("should return true when user has manage permission for the resource", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: null }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce(null); + + // User doesn't have explicit permission but has manage permission + mockRepository.checkRolePermission + .mockResolvedValueOnce(false) // explicit permission check fails + .mockResolvedValueOnce(true); // manage permission check succeeds + + const result = await service.checkPermission({ + userId: 1, + teamId: 1, + permission: "eventType.create", + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(true); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.create"); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.manage"); + }); + + it("should return true when user has manage permission at org level", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "MEMBER" as MembershipRole, + customRoleId: "member_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: 2 }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce({ + id: 2, + teamId: 2, + userId: 1, + customRoleId: "admin_role", + }); + + // Team level permissions fail, org level manage permission succeeds + mockRepository.checkRolePermission + .mockResolvedValueOnce(false) // team explicit permission + .mockResolvedValueOnce(false) // team manage permission + .mockResolvedValueOnce(true); // org manage permission + + const result = await service.checkPermission({ + userId: 1, + teamId: 1, + permission: "role.delete", + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(true); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "role.manage"); + }); + }); + + describe("hasPermissions with manage permissions", () => { + it("should return true when user has manage permissions for all requested resources", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: null }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce(null); + + // Explicit permissions check fails, but manage permissions succeed + mockRepository.checkRolePermissions.mockResolvedValueOnce(false); + mockRepository.checkRolePermission + .mockResolvedValueOnce(true) // eventType.manage + .mockResolvedValueOnce(true); // role.manage + + const result = await service.checkPermissions({ + userId: 1, + teamId: 1, + permissions: ["eventType.create", "eventType.update", "role.delete"], + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(true); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.manage"); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "role.manage"); + }); + + it("should return false when user has manage permission for some but not all requested resources", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: 2 }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce({ + id: 2, + teamId: 2, + userId: 1, + customRoleId: "admin_role", + }); + + // All explicit permission checks fail + mockRepository.checkRolePermissions + .mockResolvedValueOnce(false) // team permissions + .mockResolvedValueOnce(false); // org permissions + + // Has manage for eventType but not for booking + mockRepository.checkRolePermission + .mockResolvedValueOnce(true) // team eventType.manage + .mockResolvedValueOnce(false) // team booking.manage + .mockResolvedValueOnce(true) // org eventType.manage (duplicate check) + .mockResolvedValueOnce(false); // org booking.manage + + const result = await service.checkPermissions({ + userId: 1, + teamId: 1, + permissions: ["eventType.create", "booking.update"], + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(false); + }); }); }); diff --git a/packages/features/pbac/services/permission-check.service.ts b/packages/features/pbac/services/permission-check.service.ts index c96120b2d3dd51..7656b189f93ff7 100644 --- a/packages/features/pbac/services/permission-check.service.ts +++ b/packages/features/pbac/services/permission-check.service.ts @@ -12,6 +12,7 @@ import type { CrudAction, CustomAction, } from "../domain/types/permission-registry"; +import { PERMISSION_REGISTRY, filterResourceConfig } from "../domain/types/permission-registry"; import { PermissionRepository } from "../infrastructure/repositories/PermissionRepository"; import { PermissionService } from "./permission.service"; @@ -79,6 +80,19 @@ export class PermissionCheckService { orgActions.forEach((action) => actions.add(action)); } + // Check if user has "manage" permission - if so, grant all actions for this resource + if (actions.has("manage" as CrudAction)) { + // Get all possible actions for this resource from the permission registry + const resourceConfig = PERMISSION_REGISTRY[resource]; + if (resourceConfig) { + const allActions = Object.keys(filterResourceConfig(resourceConfig)) as ( + | CrudAction + | CustomAction + )[]; + allActions.forEach((action) => actions.add(action)); + } + } + return Array.from(actions).map((action) => PermissionMapper.toPermissionString({ resource, action })); } catch (error) { this.logger.error(error); @@ -197,11 +211,22 @@ export class PermissionCheckService { permission ); if (hasTeamPermission) return true; + + // Check if user has manage permission for this resource + const [resource] = permission.split("."); + const managePermission = `${resource}.manage` as PermissionString; + const hasManagePermission = await this.repository.checkRolePermission( + membership.customRoleId, + managePermission + ); + if (hasManagePermission) return true; } // If no team permission, check org-level permissions if (orgMembership?.customRoleId) { - return this.repository.checkRolePermission(orgMembership.customRoleId, permission); + const [resource] = permission.split("."); + const managePermission = `${resource}.manage` as PermissionString; + return this.repository.checkRolePermission(orgMembership.customRoleId, managePermission); } return false; @@ -211,6 +236,11 @@ export class PermissionCheckService { * Internal method to check multiple permissions for a specific role */ private async hasPermissions(query: PermissionCheck, permissions: PermissionString[]): Promise { + // Return false for empty permissions array to prevent privilege escalation + if (permissions.length === 0) { + return false; + } + const { membership, orgMembership } = await this.getMembership(query); // First check team-level permissions @@ -220,11 +250,61 @@ export class PermissionCheckService { permissions ); if (hasTeamPermissions) return true; + + // Check if user has manage permissions for all requested resources + const resourcesWithManage = new Set(); + for (const permission of permissions) { + const [resource] = permission.split("."); + if (!resourcesWithManage.has(resource)) { + const managePermission = `${resource}.manage` as PermissionString; + const hasManagePermission = await this.repository.checkRolePermission( + membership.customRoleId, + managePermission + ); + if (hasManagePermission) { + resourcesWithManage.add(resource); + } + } + } + + // Check if all requested permissions are covered by manage permissions + const allPermissionsCovered = permissions.every((permission) => { + const [resource] = permission.split("."); + return resourcesWithManage.has(resource); + }); + + if (allPermissionsCovered) return true; } // If no team permissions, check org-level permissions if (orgMembership?.customRoleId) { - return this.repository.checkRolePermissions(orgMembership.customRoleId, permissions); + const hasOrgPermissions = await this.repository.checkRolePermissions( + orgMembership.customRoleId, + permissions + ); + if (hasOrgPermissions) return true; + + // Check if user has manage permissions for all requested resources at org level + const resourcesWithManage = new Set(); + for (const permission of permissions) { + const [resource] = permission.split("."); + if (!resourcesWithManage.has(resource)) { + const managePermission = `${resource}.manage` as PermissionString; + const hasManagePermission = await this.repository.checkRolePermission( + orgMembership.customRoleId, + managePermission + ); + if (hasManagePermission) { + resourcesWithManage.add(resource); + } + } + } + + // Check if all requested permissions are covered by manage permissions + return permissions.every((permission) => { + const [resource] = permission.split("."); + return resourcesWithManage.has(resource); + }); } return false; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 761c7ed9cda893..7f90985e928bf7 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -100,6 +100,7 @@ const commons = { destinationCalendar: null, team: null, lockTimeZoneToggleOnBookingPage: false, + lockedTimeZone: null, requiresConfirmation: false, requiresConfirmationForFreeEmail: false, requiresBookerEmailVerification: false, diff --git a/packages/lib/event-types/getEventTypeById.test.ts b/packages/lib/event-types/getEventTypeById.test.ts new file mode 100644 index 00000000000000..2ceebe28cb310a --- /dev/null +++ b/packages/lib/event-types/getEventTypeById.test.ts @@ -0,0 +1,327 @@ +import prismock from "../../../tests/libs/__mocks__/prisma"; + +import { mockNoTranslations } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +import { describe, test, expect, beforeEach, vi } from "vitest"; + +import { getRawEventType } from "./getEventTypeById"; + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: (key: string) => () => key, +})); + +describe("getRawEventType", () => { + beforeEach(() => { + mockNoTranslations(); + }); + + describe("Regular user access", () => { + test("should fetch event type when user owns it", async () => { + const user = await prismock.user.create({ + data: { + username: "testuser", + email: "testuser@example.com", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Test Event Type", + slug: "test-event", + length: 30, + userId: user.id, + users: { + connect: [{ id: user.id }], + }, + }, + include: { + users: true, + }, + }); + + const result = await getRawEventType({ + userId: user.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: false, + currentOrganizationId: null, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.title).toBe("Test Event Type"); + expect(result?.userId).toBe(user.id); + }); + + test.skip("should return null when user doesn't have access to event type", async () => { + // note(Lauris): test skipped because somehow when creating event type eventType.users includes otherUser + const owner = await prismock.user.create({ + data: { + username: "owner", + email: "owner1@example.com", + }, + }); + + const otherUser = await prismock.user.create({ + data: { + username: "otheruser", + email: "otheruser@example.com", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Owner's Event Type", + slug: "owner-event", + length: 30, + userId: owner.id, + users: { + connect: [{ id: owner.id }], + }, + }, + select: { + id: true, + userId: true, + users: true, + }, + }); + + await prismock.user.update({ + where: { + id: otherUser.id, + }, + data: { + eventTypes: { + disconnect: [{ id: eventType.id }], + }, + }, + }); + + const result = await getRawEventType({ + userId: otherUser.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: false, + currentOrganizationId: null, + prisma: prismock as any, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Organization admin access", () => { + test("should fetch team event type when user is org admin", async () => { + const organization = await prismock.team.create({ + data: { + id: 100, + name: "Test Organization", + slug: "test-org", + isOrganization: true, + }, + }); + + const team = await prismock.team.create({ + data: { + id: 200, + name: "Test Team", + slug: "test-team", + parentId: organization.id, + }, + }); + + const orgAdmin = await prismock.user.create({ + data: { + username: "orgadmin", + email: "orgadmin@example.com", + organizationId: organization.id, + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Team Event Type", + slug: "team-event", + length: 30, + teamId: team.id, + }, + include: { + team: true, + users: true, + }, + }); + + const result = await getRawEventType({ + userId: orgAdmin.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: organization.id, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.title).toBe("Team Event Type"); + expect(result?.teamId).toBe(team.id); + }); + + test("should fetch user event type when user is org admin and user belongs to org", async () => { + const organization = await prismock.team.create({ + data: { + id: 101, + name: "Test Organization 2", + slug: "test-org-2", + isOrganization: true, + }, + }); + + const orgAdmin = await prismock.user.create({ + data: { + username: "orgadmin2", + email: "orgadmin2@example.com", + organizationId: organization.id, + }, + }); + + const orgUser = await prismock.user.create({ + data: { + username: "orguser", + email: "orguser@example.com", + organizationId: organization.id, + profiles: { + create: { + organizationId: organization.id, + uid: "orguser", + username: "orguser", + }, + }, + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Org User Event Type", + slug: "org-user-event", + length: 30, + userId: orgUser.id, + users: { + connect: [{ id: orgUser.id }], + }, + }, + include: { + users: true, + owner: true, + }, + }); + + const result = await getRawEventType({ + userId: orgAdmin.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: organization.id, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.title).toBe("Org User Event Type"); + expect(result?.userId).toBe(orgUser.id); + }); + + test("should return null when org admin tries to access event type from different org", async () => { + const org1 = await prismock.team.create({ + data: { + id: 102, + name: "Organization 1", + slug: "org-1", + isOrganization: true, + }, + }); + + const org2 = await prismock.team.create({ + data: { + id: 103, + name: "Organization 2", + slug: "org-2", + isOrganization: true, + }, + }); + + const team1 = await prismock.team.create({ + data: { + id: 201, + name: "Team in Org 1", + slug: "team-org-1", + parentId: org1.id, + }, + }); + + const org2Admin = await prismock.user.create({ + data: { + username: "org2admin", + email: "org2admin@example.com", + organizationId: org2.id, + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Org 1 Team Event", + slug: "org1-team-event", + length: 30, + teamId: team1.id, + }, + include: { + team: true, + users: true, + }, + }); + + const result = await getRawEventType({ + userId: org2Admin.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: org2.id, + prisma: prismock as any, + }); + + expect(result).toBeNull(); + }); + + test("should fallback to regular user access when org admin flag is true but no organizationId", async () => { + const user = await prismock.user.create({ + data: { + username: "regularuser", + email: "regularuser@example.com", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Regular User Event", + slug: "regular-user-event", + length: 30, + userId: user.id, + users: { + connect: [{ id: user.id }], + }, + }, + include: { + users: true, + owner: true, + }, + }); + + const result = await getRawEventType({ + userId: user.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: null, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.userId).toBe(user.id); + }); + }); +}); diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index ab9f901576ab74..56e7ab28b48c0e 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -53,8 +53,13 @@ export const getEventTypeById = async ({ timeZone: true, } satisfies Prisma.UserSelect; - const eventTypeRepo = new EventTypeRepository(prisma); - const rawEventType = await eventTypeRepo.findById({ id: eventTypeId, userId }); + const rawEventType = await getRawEventType({ + userId, + eventTypeId, + isUserOrganizationAdmin, + currentOrganizationId, + prisma, + }); if (!rawEventType) { if (isTrpcCall) { @@ -260,4 +265,26 @@ export const getEventTypeById = async ({ return finalObj; }; +export async function getRawEventType({ + userId, + eventTypeId, + isUserOrganizationAdmin, + currentOrganizationId, + prisma, +}: Omit) { + const eventTypeRepo = new EventTypeRepository(prisma); + + if (isUserOrganizationAdmin && currentOrganizationId) { + return await eventTypeRepo.findByIdForOrgAdmin({ + id: eventTypeId, + organizationId: currentOrganizationId, + }); + } + + return await eventTypeRepo.findById({ + id: eventTypeId, + userId, + }); +} + export default getEventTypeById; diff --git a/packages/lib/event-types/getEventTypesPublic.ts b/packages/lib/event-types/getEventTypesPublic.ts index 34f462d2e2e303..a884d21abe76a7 100644 --- a/packages/lib/event-types/getEventTypesPublic.ts +++ b/packages/lib/event-types/getEventTypesPublic.ts @@ -35,14 +35,14 @@ const getEventTypesWithHiddenFromDB = async (userId: number) => { const eventTypes = await prisma.$queryRaw` SELECT data."id", data."title", data."description", data."length", data."schedulingType"::text, data."recurringEvent", data."slug", data."hidden", data."price", data."currency", - data."lockTimeZoneToggleOnBookingPage", data."requiresConfirmation", data."requiresBookerEmailVerification", + data."lockTimeZoneToggleOnBookingPage", data."lockedTimeZone", data."requiresConfirmation", data."requiresBookerEmailVerification", data."metadata", data."canSendCalVideoTranscriptionEmails" FROM ( SELECT "EventType"."id", "EventType"."title", "EventType"."description", "EventType"."position", "EventType"."length", "EventType"."schedulingType"::text, "EventType"."recurringEvent", "EventType"."slug", "EventType"."hidden", "EventType"."price", "EventType"."currency", - "EventType"."lockTimeZoneToggleOnBookingPage", "EventType"."requiresConfirmation", + "EventType"."lockTimeZoneToggleOnBookingPage", "EventType"."lockedTimeZone", "EventType"."requiresConfirmation", "EventType"."requiresBookerEmailVerification", "EventType"."metadata", "EventType"."canSendCalVideoTranscriptionEmails" FROM "EventType" @@ -52,7 +52,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number) => { "EventType"."position", "EventType"."length", "EventType"."schedulingType"::text, "EventType"."recurringEvent", "EventType"."slug", "EventType"."hidden", "EventType"."price", "EventType"."currency", - "EventType"."lockTimeZoneToggleOnBookingPage", "EventType"."requiresConfirmation", + "EventType"."lockTimeZoneToggleOnBookingPage", "EventType"."lockedTimeZone", "EventType"."requiresConfirmation", "EventType"."requiresBookerEmailVerification", "EventType"."metadata", "EventType"."canSendCalVideoTranscriptionEmails" FROM "EventType" diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 06c20e1f579e42..bfad34da4f4f1b 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -34,6 +34,7 @@ export const eventTypeSelect = { periodDays: true, periodCountCalendarDays: true, lockTimeZoneToggleOnBookingPage: true, + lockedTimeZone: true, requiresBookerEmailVerification: true, disableGuests: true, disableCancelling: true, diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index fa3f8ff80b4e76..18bd8f960b89b9 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -548,6 +548,7 @@ export class EventTypeRepository { periodEndDate: true, periodCountCalendarDays: true, lockTimeZoneToggleOnBookingPage: true, + lockedTimeZone: true, requiresConfirmation: true, requiresConfirmationForFreeEmail: true, canSendCalVideoTranscriptionEmails: true, @@ -799,6 +800,282 @@ export class EventTypeRepository { }); } + async findByIdForOrgAdmin({ id, organizationId }: { id: number; organizationId: number }) { + const userSelect = { + name: true, + avatarUrl: true, + username: true, + id: true, + email: true, + locale: true, + defaultScheduleId: true, + isPlatformManaged: true, + timeZone: true, + } satisfies Prisma.UserSelect; + + const CompleteEventTypeSelect = { + id: true, + title: true, + slug: true, + description: true, + interfaceLanguage: true, + length: true, + isInstantEvent: true, + instantMeetingExpiryTimeOffsetInSeconds: true, + instantMeetingParameters: true, + aiPhoneCallConfig: true, + offsetStart: true, + hidden: true, + locations: true, + eventName: true, + customInputs: true, + timeZone: true, + periodType: true, + metadata: true, + periodDays: true, + periodStartDate: true, + periodEndDate: true, + periodCountCalendarDays: true, + lockTimeZoneToggleOnBookingPage: true, + requiresConfirmation: true, + requiresConfirmationForFreeEmail: true, + canSendCalVideoTranscriptionEmails: true, + requiresConfirmationWillBlockSlot: true, + requiresBookerEmailVerification: true, + autoTranslateDescriptionEnabled: true, + fieldTranslations: { + select: { + translatedText: true, + targetLocale: true, + field: true, + }, + }, + recurringEvent: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + disableGuests: true, + disableCancelling: true, + disableRescheduling: true, + allowReschedulingCancelledBookings: true, + minimumBookingNotice: true, + beforeEventBuffer: true, + afterEventBuffer: true, + slotInterval: true, + hashedLink: hashedLinkSelect, + eventTypeColor: true, + bookingLimits: true, + onlyShowFirstAvailableSlot: true, + durationLimits: true, + maxActiveBookingsPerBooker: true, + maxActiveBookingPerBookerOfferReschedule: true, + assignAllTeamMembers: true, + allowReschedulingPastBookings: true, + hideOrganizerEmail: true, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, + isRRWeightsEnabled: true, + rescheduleWithSameRoundRobinHost: true, + successRedirectUrl: true, + forwardParamsSuccessRedirect: true, + currency: true, + bookingFields: true, + useEventTypeDestinationCalendarEmail: true, + customReplyToEmail: true, + owner: { + select: { + id: true, + timeZone: true, + }, + }, + parent: { + select: { + id: true, + teamId: true, + }, + }, + teamId: true, + team: { + select: { + id: true, + name: true, + slug: true, + parentId: true, + rrTimestampBasis: true, + parent: { + select: { + slug: true, + organizationSettings: { + select: { + lockEventTypeCreationForUsers: true, + }, + }, + }, + }, + members: { + select: { + role: true, + accepted: true, + user: { + select: { + ...userSelect, + eventTypes: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + }, + }, + restrictionScheduleId: true, + useBookerTimezone: true, + users: { + select: userSelect, + }, + schedulingType: true, + schedule: { + select: { + id: true, + name: true, + }, + }, + instantMeetingSchedule: { + select: { + id: true, + name: true, + }, + }, + restrictionSchedule: { + select: { + id: true, + name: true, + }, + }, + hosts: { + select: { + isFixed: true, + userId: true, + priority: true, + weight: true, + scheduleId: true, + user: { + select: { + timeZone: true, + }, + }, + }, + }, + userId: true, + price: true, + children: { + select: { + owner: { + select: { + avatarUrl: true, + name: true, + username: true, + email: true, + id: true, + }, + }, + hidden: true, + slug: true, + }, + }, + destinationCalendar: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + webhooks: { + select: { + id: true, + subscriberUrl: true, + payloadTemplate: true, + active: true, + eventTriggers: true, + secret: true, + eventTypeId: true, + }, + }, + workflows: { + include: { + workflow: { + select: { + name: true, + id: true, + trigger: true, + time: true, + timeUnit: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + parentId: true, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }, + steps: true, + }, + }, + }, + }, + secondaryEmailId: true, + maxLeadThreshold: true, + includeNoShowInRRCalculation: true, + useEventLevelSelectedCalendars: true, + calVideoSettings: { + select: { + disableRecordingForGuests: true, + disableRecordingForOrganizer: true, + enableAutomaticTranscription: true, + enableAutomaticRecordingForOrganizer: true, + disableTranscriptionForGuests: true, + disableTranscriptionForOrganizer: true, + redirectUrlOnExit: true, + }, + }, + } satisfies Prisma.EventTypeSelect; + + const orgUserEventTypeQuery = { + AND: [{ userId: { not: null } }, { owner: { profiles: { some: { organizationId } } } }], + }; + const orgTeamEventTypeQuery = { + AND: [{ teamId: { not: null } }, { team: { parentId: organizationId } }], + }; + + return await this.prismaClient.eventType.findFirst({ + where: { + AND: [ + { id }, + { + OR: [orgUserEventTypeQuery, orgTeamEventTypeQuery], + }, + ], + }, + select: CompleteEventTypeSelect, + }); + } + async findByIdMinimal({ id }: { id: number }) { return await this.prismaClient.eventType.findUnique({ where: { diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 08a3a7e74685de..d76e02a3731ca2 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -1,13 +1,32 @@ import { Prisma } from "@prisma/client"; +import md5 from "md5"; import { z } from "zod"; -import type { DateRange } from "@calcom/features/insights/server/events"; +import dayjs from "@calcom/dayjs"; +import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { readonlyPrisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRepository } from "../repository/membership"; import { TeamRepository } from "../repository/team"; +// Utility function to build user hash map with avatar URL fallback +export const buildHashMapForUsers = < + T extends { avatarUrl: string | null; id: number; username: string | null; [key: string]: unknown } +>( + usersFromTeam: T[] +) => { + const userHashMap = new Map & { avatarUrl: string }>(); + usersFromTeam.forEach((user) => { + userHashMap.set(user.id, { + ...user, + // TODO: Use AVATAR_FALLBACK when avatar.png endpoint is fased out + avatarUrl: user.avatarUrl || `/${user.username}/avatar.png`, + }); + }); + return userHashMap; +}; + // Type definition for BookingTimeStatusDenormalized view export type BookingTimeStatusDenormalized = z.infer; @@ -261,16 +280,22 @@ export class InsightsBookingService { if (!this.options) { return NOTHING_CONDITION; } - const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId); - if (!isOwnerOrAdmin) { - return NOTHING_CONDITION; + const scope = this.options.scope; + const targetId = + scope === "org" ? this.options.orgId : scope === "team" ? this.options.teamId : undefined; + + if (targetId && scope !== "user") { + const isOwnerOrAdmin = await this.isOwnerOrAdmin(this.options.userId, targetId); + if (!isOwnerOrAdmin) { + return NOTHING_CONDITION; + } } - if (this.options.scope === "user") { + if (scope === "user") { return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`; - } else if (this.options.scope === "org") { + } else if (scope === "org") { return await this.buildOrgAuthorizationCondition(this.options); - } else if (this.options.scope === "team") { + } else if (scope === "team") { return await this.buildTeamAuthorizationCondition(this.options); } else { return NOTHING_CONDITION; @@ -318,7 +343,7 @@ export class InsightsBookingService { parentId: options.orgId, select: { id: true }, }); - if (!childTeamOfOrg) { + if (options.orgId && !childTeamOfOrg) { return NOTHING_CONDITION; } @@ -659,9 +684,386 @@ export class InsightsBookingService { return result; } - private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { - // Check if the user is an owner or admin of the organization - const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); + async getPopularEventsStats() { + const baseConditions = await this.getBaseConditions(); + + const bookingsFromSelected = await this.prisma.$queryRaw< + Array<{ + eventTypeId: number; + count: number; + }> + >` + SELECT + "eventTypeId", + COUNT(id)::int as count + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "eventTypeId" IS NOT NULL + GROUP BY "eventTypeId" + ORDER BY count DESC + LIMIT 10 + `; + + const eventTypeIds = bookingsFromSelected.map((booking) => booking.eventTypeId); + + if (eventTypeIds.length === 0) { + return []; + } + + const eventTypesFrom = await this.prisma.eventType.findMany({ + select: { + id: true, + title: true, + teamId: true, + userId: true, + slug: true, + users: { + select: { + username: true, + }, + }, + team: { + select: { + slug: true, + }, + }, + }, + where: { + id: { + in: eventTypeIds, + }, + }, + }); + + const eventTypeHashMap = new Map(eventTypesFrom.map((eventType) => [eventType.id, eventType])); + + const result = bookingsFromSelected + .map((booking) => { + const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId); + if (!eventTypeSelected) { + return null; + } + + let eventSlug = ""; + if (eventTypeSelected.userId) { + eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`; + } + if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) { + eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`; + } + return { + eventTypeId: booking.eventTypeId, + eventTypeName: eventSlug, + count: booking.count, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getMembersStatsWithCount( + type: "all" | "cancelled" | "noShow" = "all", + sortOrder: "ASC" | "DESC" = "DESC" + ) { + const baseConditions = await this.getBaseConditions(); + + let additionalCondition = Prisma.sql``; + if (type === "cancelled") { + additionalCondition = Prisma.sql`AND status = 'cancelled'`; + } else if (type === "noShow") { + additionalCondition = Prisma.sql`AND "noShowHost" = true`; + } + + const bookingsFromTeam = await this.prisma.$queryRaw< + Array<{ + userId: number; + count: number; + }> + >` + SELECT + "userId", + COUNT(id)::int as count + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "userId" IS NOT NULL ${additionalCondition} + GROUP BY "userId" + ORDER BY count ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`} + LIMIT 10 + `; + + if (bookingsFromTeam.length === 0) { + return []; + } + + const userIds = bookingsFromTeam.map((booking) => booking.userId); + + const usersFromTeam = await this.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam + .map((booking) => { + const user = userHashMap.get(booking.userId); + if (!user) { + return null; + } + + return { + userId: booking.userId, + user, + emailMd5: md5(user.email || ""), + count: booking.count, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC") { + const baseConditions = await this.getBaseConditions(); + + const bookingsFromTeam = await this.prisma.$queryRaw< + Array<{ + userId: number; + averageRating: number; + }> + >` + SELECT + "userId", + AVG("rating")::float as "averageRating" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "userId" IS NOT NULL AND "rating" IS NOT NULL + GROUP BY "userId" + ORDER BY "averageRating" ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`} + LIMIT 10 + `; + + if (bookingsFromTeam.length === 0) { + return []; + } + + const userIds = bookingsFromTeam.map((booking) => booking.userId); + + const usersFromTeam = await this.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam + .map((booking) => { + const user = userHashMap.get(booking.userId); + if (!user) { + return null; + } + + return { + userId: booking.userId, + user, + emailMd5: md5(user.email), + averageRating: booking.averageRating, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getRecentRatingsStats() { + const baseConditions = await this.getBaseConditions(); + + const bookingsFromTeam = await this.prisma.$queryRaw< + Array<{ + userId: number | null; + rating: number | null; + ratingFeedback: string | null; + }> + >` + SELECT + "userId", + "rating", + "ratingFeedback" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "ratingFeedback" IS NOT NULL + ORDER BY "endTime" DESC + LIMIT 10 + `; + + if (bookingsFromTeam.length === 0) { + return []; + } + + const userIds = bookingsFromTeam + .filter((booking) => booking.userId !== null) + .map((booking) => booking.userId as number) + .filter((userId, index, array) => array.indexOf(userId) === index); + + if (userIds.length === 0) { + return []; + } + + const usersFromTeam = await this.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam + .map((booking) => { + if (!booking.userId) { + return null; + } + + const user = userHashMap.get(booking.userId); + if (!user) { + return null; + } + + return { + userId: booking.userId, + user, + emailMd5: md5(user.email), + rating: booking.rating, + feedback: booking.ratingFeedback, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getBookingStats() { + const baseConditions = await this.getBaseConditions(); + + const stats = await this.prisma.$queryRaw< + Array<{ + total_bookings: bigint; + completed_bookings: bigint; + rescheduled_bookings: bigint; + cancelled_bookings: bigint; + no_show_host_bookings: bigint; + avg_rating: number | null; + total_ratings: bigint; + ratings_above_3: bigint; + no_show_guests: bigint; + }> + >` + WITH booking_stats AS ( + SELECT + COUNT(*) as total_bookings, + COUNT(CASE WHEN "timeStatus" = 'completed' THEN 1 END) as completed_bookings, + COUNT(CASE WHEN "timeStatus" = 'rescheduled' THEN 1 END) as rescheduled_bookings, + COUNT(CASE WHEN "timeStatus" = 'cancelled' THEN 1 END) as cancelled_bookings, + COUNT(CASE WHEN "noShowHost" = true THEN 1 END) as no_show_host_bookings, + AVG(CASE WHEN "rating" IS NOT NULL THEN "rating" END) as avg_rating, + COUNT(CASE WHEN "rating" IS NOT NULL THEN 1 END) as total_ratings, + COUNT(CASE WHEN "rating" > 3 THEN 1 END) as ratings_above_3 + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + ), + guest_stats AS ( + SELECT COUNT(*) as no_show_guests + FROM "Attendee" a + INNER JOIN "BookingTimeStatusDenormalized" b ON a."bookingId" = b.id + WHERE ${baseConditions} AND a."noShow" = true + ) + SELECT + bs.total_bookings, + bs.completed_bookings, + bs.rescheduled_bookings, + bs.cancelled_bookings, + bs.no_show_host_bookings, + bs.avg_rating, + bs.total_ratings, + bs.ratings_above_3, + gs.no_show_guests + FROM booking_stats bs, guest_stats gs + `; + + const rawStats = stats[0]; + return rawStats + ? { + total_bookings: Number(rawStats.total_bookings), + completed_bookings: Number(rawStats.completed_bookings), + rescheduled_bookings: Number(rawStats.rescheduled_bookings), + cancelled_bookings: Number(rawStats.cancelled_bookings), + no_show_host_bookings: Number(rawStats.no_show_host_bookings), + avg_rating: rawStats.avg_rating, + total_ratings: Number(rawStats.total_ratings), + ratings_above_3: Number(rawStats.ratings_above_3), + no_show_guests: Number(rawStats.no_show_guests), + } + : { + total_bookings: 0, + completed_bookings: 0, + rescheduled_bookings: 0, + cancelled_bookings: 0, + no_show_host_bookings: 0, + avg_rating: 0, + total_ratings: 0, + ratings_above_3: 0, + no_show_guests: 0, + }; + } + + calculatePreviousPeriodDates() { + if (!this.filters?.dateRange) { + throw new Error("Date range is required for calculating previous period"); + } + + const startDate = dayjs(this.filters.dateRange.startDate); + const endDate = dayjs(this.filters.dateRange.endDate); + const startTimeEndTimeDiff = endDate.diff(startDate, "day"); + + const lastPeriodStartDate = startDate.subtract(startTimeEndTimeDiff, "day"); + const lastPeriodEndDate = endDate.subtract(startTimeEndTimeDiff, "day"); + + return { + startDate: lastPeriodStartDate.toISOString(), + endDate: lastPeriodEndDate.toISOString(), + formattedStartDate: lastPeriodStartDate.format("YYYY-MM-DD"), + formattedEndDate: lastPeriodEndDate.format("YYYY-MM-DD"), + }; + } + + private async isOwnerOrAdmin(userId: number, targetId: number): Promise { + // Check if the user is an owner or admin of the organization or team + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: targetId }); return Boolean( membership && membership.accepted && diff --git a/packages/lib/server/service/insightsRoutingBase.ts b/packages/lib/server/service/insightsRoutingBase.ts index e1bc80ad5bc850..e5c8fe3b0458a2 100644 --- a/packages/lib/server/service/insightsRoutingBase.ts +++ b/packages/lib/server/service/insightsRoutingBase.ts @@ -10,7 +10,7 @@ import { isNumberFilterValue, isSingleSelectFilterValue, } from "@calcom/features/data-table/lib/utils"; -import type { DateRange } from "@calcom/features/insights/server/events"; +import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { readonlyPrisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -306,16 +306,23 @@ export class InsightsRoutingBaseService { if (!this.options) { return NOTHING_CONDITION; } - const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId); - if (!isOwnerOrAdmin) { - return NOTHING_CONDITION; + + const scope = this.options.scope; + const targetId = + scope === "org" ? this.options.orgId : scope === "team" ? this.options.teamId : undefined; + + if (targetId && scope !== "user") { + const isOwnerOrAdmin = await this.isOwnerOrAdmin(this.options.userId, targetId); + if (!isOwnerOrAdmin) { + return NOTHING_CONDITION; + } } - if (this.options.scope === "user") { + if (scope === "user") { return Prisma.sql`"formUserId" = ${this.options.userId} AND "formTeamId" IS NULL`; - } else if (this.options.scope === "org") { + } else if (scope === "org") { return await this.buildOrgAuthorizationCondition(this.options); - } else if (this.options.scope === "team") { + } else if (scope === "team") { return await this.buildTeamAuthorizationCondition(this.options); } else { return NOTHING_CONDITION; @@ -346,16 +353,16 @@ export class InsightsRoutingBaseService { parentId: options.orgId, select: { id: true }, }); - if (!childTeamOfOrg) { + if (options.orgId && !childTeamOfOrg) { return NOTHING_CONDITION; } return Prisma.sql`"formTeamId" = ${options.teamId}`; } - private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { - // Check if the user is an owner or admin of the organization - const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); + private async isOwnerOrAdmin(userId: number, targetId: number): Promise { + // Check if the user is an owner or admin of the organization or team + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: targetId }); return Boolean( membership && membership.accepted && diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 3860088336a35b..f967e421c26793 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -112,6 +112,7 @@ export const buildEventType = (eventType?: Partial): EventType => { periodCountCalendarDays: null, recurringEvent: null, lockTimeZoneToggleOnBookingPage: false, + lockedTimeZone: null, requiresConfirmation: false, requiresConfirmationForFreeEmail: false, requiresConfirmationWillBlockSlot: false, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 94fcb9822887cb..0bca6f53f74542 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -59,6 +59,7 @@ export const useEventTypeForm = ({ seatsShowAttendees: eventType.seatsShowAttendees, seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount, lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage, + lockedTimeZone: eventType.lockedTimeZone || null, locations: eventType.locations || [], destinationCalendar: eventType.destinationCalendar, recurringEvent: eventType.recurringEvent || null, diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx index f28e99ef04993d..d35433c5841269 100644 --- a/packages/platform/examples/base/src/pages/_app.tsx +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -13,8 +13,8 @@ import "@calcom/atoms/globals.min.css"; const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] }); type TUser = Data["users"][0]; -function generateRandomEmail() { - const localPartLength = 10; +function generateRandomEmail(name: string) { + const localPartLength = 5; const domain = ["example.com", "example.net", "example.org"]; const randomLocalPart = Array.from({ length: localPartLength }, () => @@ -23,7 +23,7 @@ function generateRandomEmail() { const randomDomain = domain[Math.floor(Math.random() * domain.length)]; - return `${randomLocalPart}@${randomDomain}`; + return `${name}-${randomLocalPart}@${randomDomain}`; } // note(Lauris): needed because useEffect kicks in twice creating 2 parallel requests @@ -50,11 +50,11 @@ export default function App({ Component, pageProps }: AppProps) { }, []); useEffect(() => { - const randomEmailOne = generateRandomEmail(); - const randomEmailTwo = generateRandomEmail(); - const randomEmailThree = generateRandomEmail(); - const randomEmailFour = generateRandomEmail(); - const randomEmailFive = generateRandomEmail(); + const randomEmailOne = generateRandomEmail("keith"); + const randomEmailTwo = generateRandomEmail("somay"); + const randomEmailThree = generateRandomEmail("rajiv"); + const randomEmailFour = generateRandomEmail("morgan"); + const randomEmailFive = generateRandomEmail("lauris"); if (!seeding) { seeding = true; diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts index 4cbb1c4355b775..ee6013ac3bcbfc 100644 --- a/packages/platform/examples/base/src/pages/api/managed-user.ts +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -12,56 +12,14 @@ type Data = { accessToken: string; }; -// example endpoint to create a managed cal.com user -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { emails } = JSON.parse(req.body); - const emailOne = emails[0]; - const emailTwo = emails[1]; - const emailThree = emails[2]; - const emailFour = emails[3]; - const emailFive = emails[4]; - - const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } }); - if (existingUser && existingUser.calcomUserId) { - return res.status(200).json({ - id: existingUser.calcomUserId, - email: existingUser.email, - username: existingUser.calcomUsername ?? "", - accessToken: existingUser.accessToken ?? "", - }); - } - - const localUserOne = await prisma.user.create({ - data: { - email: emailOne, - }, - }); - - const localUserTwo = await prisma.user.create({ +async function createUserWithDefaultSchedule(email: string, name: string, avatarUrl: string) { + const localUser = await prisma.user.create({ data: { - email: emailTwo, + email, }, }); - const localUserThree = await prisma.user.create({ - data: { - email: emailThree, - }, - }); - - const localUserFour = await prisma.user.create({ - data: { - email: emailFour, - }, - }); - - const localUserFive = await prisma.user.create({ - data: { - email: emailFive, - }, - }); - - const response = await fetch( + const managedUserResponse = await fetch( // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, { @@ -73,153 +31,74 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< origin: "http://localhost:4321", }, body: JSON.stringify({ - email: emailOne, - name: "John Jones", - avatarUrl: - "https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + email, + name, + avatarUrl, }), } ); - const body = await response.json(); - await prisma.user.update({ - data: { - refreshToken: (body.data?.refreshToken as string) ?? "", - accessToken: (body.data?.accessToken as string) ?? "", - calcomUserId: body.data?.user.id, - calcomUsername: (body.data?.user.username as string) ?? "", - }, - where: { id: localUserOne.id }, - }); + const managedUserResponseBody = await managedUserResponse.json(); - const responseTwo = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailTwo, - name: "Jane Doe", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } - ); - const bodyTwo = await responseTwo.json(); await prisma.user.update({ data: { - refreshToken: (bodyTwo.data?.refreshToken as string) ?? "", - accessToken: (bodyTwo.data?.accessToken as string) ?? "", - calcomUserId: bodyTwo.data?.user.id, - calcomUsername: (bodyTwo.data?.user.username as string) ?? "", + refreshToken: (managedUserResponseBody.data?.refreshToken as string) ?? "", + accessToken: (managedUserResponseBody.data?.accessToken as string) ?? "", + calcomUserId: managedUserResponseBody.data?.user.id, + calcomUsername: (managedUserResponseBody.data?.user.username as string) ?? "", }, - where: { id: localUserTwo.id }, + where: { id: localUser.id }, }); - const responseThree = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailThree, - name: "Rajiv", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } - ); - const bodyThree = await responseThree.json(); + await createDefaultSchedule(managedUserResponseBody.data?.accessToken as string); - await prisma.user.update({ - data: { - refreshToken: (bodyThree.data?.refreshToken as string) ?? "", - accessToken: (bodyThree.data?.accessToken as string) ?? "", - calcomUserId: bodyThree.data?.user.id, - calcomUsername: (bodyThree.data?.user.username as string) ?? "", - }, - where: { id: localUserThree.id }, - }); + return managedUserResponseBody.data; +} - const responseFour = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailFour, - name: "Morgan", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } - ); - const bodyFour = await responseFour.json(); +// example endpoint to create a managed cal.com user +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { emails } = JSON.parse(req.body); + const emailOne = emails[0]; + const emailTwo = emails[1]; + const emailThree = emails[2]; + const emailFour = emails[3]; + const emailFive = emails[4]; - await prisma.user.update({ - data: { - refreshToken: (bodyFour.data?.refreshToken as string) ?? "", - accessToken: (bodyFour.data?.accessToken as string) ?? "", - calcomUserId: bodyFour.data?.user.id, - calcomUsername: (bodyFour.data?.user.username as string) ?? "", - }, - where: { id: localUserFour.id }, - }); + const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } }); + if (existingUser && existingUser.calcomUserId) { + return res.status(200).json({ + id: existingUser.calcomUserId, + email: existingUser.email, + username: existingUser.calcomUsername ?? "", + accessToken: existingUser.accessToken ?? "", + }); + } - const responseFive = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailFive, - name: "Lauris", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } + const managedUserResponseOne = await createUserWithDefaultSchedule( + emailOne, + "Keith", + "https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseTwo = await createUserWithDefaultSchedule( + emailTwo, + "Somay", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseThree = await createUserWithDefaultSchedule( + emailThree, + "Rajiv", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseFour = await createUserWithDefaultSchedule( + emailFour, + "Morgan", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseFive = await createUserWithDefaultSchedule( + emailFive, + "Lauris", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" ); - const bodyFive = await responseFive.json(); - - await prisma.user.update({ - data: { - refreshToken: (bodyFive.data?.refreshToken as string) ?? "", - accessToken: (bodyFive.data?.accessToken as string) ?? "", - calcomUserId: bodyFive.data?.user.id, - calcomUsername: (bodyFive.data?.user.username as string) ?? "", - }, - where: { id: localUserFive.id }, - }); - - await createDefaultSchedule(body.data?.accessToken as string); - await createDefaultSchedule(bodyTwo.data?.accessToken as string); - await createDefaultSchedule(bodyThree.data?.accessToken as string); - await createDefaultSchedule(bodyFour.data?.accessToken as string); - await createDefaultSchedule(bodyFive.data?.accessToken as string); // eslint-disable-next-line turbo/no-undeclared-env-vars const organizationId = process.env.ORGANIZATION_ID; @@ -227,27 +106,37 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< throw new Error("Organization ID is not set"); } - const team = await createTeam(+organizationId, "Team Doe"); + const team = await createTeam(+organizationId, `Platform devs - ${Date.now()}`); if (!team) { throw new Error("Failed to create team. Probably your platform team does not have required plan."); } - await createMembership(+organizationId, team.id, body.data?.user.id); - await createMembership(+organizationId, team.id, bodyTwo.data?.user.id); - await createMembership(+organizationId, team.id, bodyThree.data?.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseOne.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseTwo.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseThree.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseFour.user.id); + await createCollectiveEventType(+organizationId, team.id, [ - body.data?.user.id, - bodyTwo.data?.user.id, - bodyThree.data?.user.id, - bodyFour.data?.user.id, - bodyFive.data?.user.id, + managedUserResponseOne.user.id, + managedUserResponseTwo.user.id, + managedUserResponseThree.user.id, + managedUserResponseFour.user.id, ]); + await createRoundRobinEventType(+organizationId, team.id, [ + managedUserResponseOne.user.id, + managedUserResponseTwo.user.id, + managedUserResponseThree.user.id, + managedUserResponseFour.user.id, + ]); + + await createOrgMembershipAdmin(+organizationId, managedUserResponseFive.user.id); + return res.status(200).json({ - id: body?.data?.user?.id, - email: (body.data?.user.email as string) ?? "", - username: (body.data?.username as string) ?? "", - accessToken: (body.data?.accessToken as string) ?? "", + id: managedUserResponseOne?.user?.id, + email: (managedUserResponseOne.user.email as string) ?? "", + username: (managedUserResponseOne.user.username as string) ?? "", + accessToken: (managedUserResponseOne.accessToken as string) ?? "", }); } @@ -276,10 +165,33 @@ async function createTeam(orgId: number, name: string) { return body.data; } -async function createMembership(orgId: number, teamId: number, userId: number) { +async function createOrgTeamMembershipMember(orgId: number, teamId: number, userId: number) { await fetch( // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + userId, + accepted: true, + role: "MEMBER", + }), + } + ); +} + +async function createOrgMembershipAdmin(orgId: number, userId: number) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`, { method: "POST", headers: { @@ -315,9 +227,34 @@ async function createCollectiveEventType(orgId: number, teamId: number, userIds: }, body: JSON.stringify({ lengthInMinutes: 60, - title: "Doe collective", - slug: "doe-collective", - schedulingType: "COLLECTIVE", + title: "Platform example collective", + slug: "platform-example-collective", + schedulingType: "collective", + hosts: userIds.map((userId) => ({ userId })), + }), + } + ); +} + +async function createRoundRobinEventType(orgId: number, teamId: number, userIds: number[]) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + lengthInMinutes: 60, + title: "Platform example round robin", + slug: "platform-example-round-robin", + schedulingType: "roundRobin", hosts: userIds.map((userId) => ({ userId })), }), } diff --git a/packages/platform/examples/base/src/pages/event-types.tsx b/packages/platform/examples/base/src/pages/event-types.tsx index 4e929443b3ddce..71118ef3eb8e57 100644 --- a/packages/platform/examples/base/src/pages/event-types.tsx +++ b/packages/platform/examples/base/src/pages/event-types.tsx @@ -743,6 +743,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string onSuccess={(eventType) => { setEventTypeId(null); refetch(); + refetchTeamEvents(); }} onError={(eventType, error) => { console.log(eventType); diff --git a/packages/prisma/.yarn/ci-cache/install-state.gz b/packages/prisma/.yarn/ci-cache/install-state.gz index e095f42e4afa02..9aebde9e9e68a5 100644 Binary files a/packages/prisma/.yarn/ci-cache/install-state.gz and b/packages/prisma/.yarn/ci-cache/install-state.gz differ diff --git a/packages/prisma/migrations/20250715154531_event_locked_timezone/migration.sql b/packages/prisma/migrations/20250715154531_event_locked_timezone/migration.sql new file mode 100644 index 00000000000000..653b2dd099ebb6 --- /dev/null +++ b/packages/prisma/migrations/20250715154531_event_locked_timezone/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "lockedTimeZone" TEXT; diff --git a/packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql b/packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql new file mode 100644 index 00000000000000..865bb1cf6fa3db --- /dev/null +++ b/packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql @@ -0,0 +1,24 @@ +-- Add manage permissions to admin and owner roles +-- These permissions are for organization-level management capabilities + +-- Insert manage permissions for admin role +INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt") +SELECT + gen_random_uuid(), 'admin_role', resource, action, NOW() +FROM ( + VALUES + -- Role management permissions (organization scope) + ('role', 'manage'), + + -- Event Type management permissions (organization scope) + ('eventType', 'manage'), + + -- Team management permissions (organization scope) + ('team', 'manage'), + + -- Booking management permissions (organization scope) + ('booking', 'manage') +) AS permissions(resource, action) +ON CONFLICT ("roleId", resource, action) DO NOTHING; + +-- Note: Owner role already has wildcard permissions (*.*) so it inherits all manage permissions automatically \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8298a97a7985aa..14c2e73086f21d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -130,6 +130,7 @@ model EventType { periodDays Int? periodCountCalendarDays Boolean? lockTimeZoneToggleOnBookingPage Boolean @default(false) + lockedTimeZone String? requiresConfirmation Boolean @default(false) requiresConfirmationWillBlockSlot Boolean @default(false) requiresConfirmationForFreeEmail Boolean @default(false) diff --git a/packages/prisma/selects/event-types.ts b/packages/prisma/selects/event-types.ts index 5d76de39736eee..f98fd3489418c8 100644 --- a/packages/prisma/selects/event-types.ts +++ b/packages/prisma/selects/event-types.ts @@ -12,6 +12,7 @@ export const baseEventTypeSelect = { price: true, currency: true, lockTimeZoneToggleOnBookingPage: true, + lockedTimeZone: true, requiresConfirmation: true, requiresBookerEmailVerification: true, canSendCalVideoTranscriptionEmails: true, @@ -31,6 +32,7 @@ export const bookEventTypeSelect = { periodEndDate: true, recurringEvent: true, lockTimeZoneToggleOnBookingPage: true, + lockedTimeZone: true, requiresConfirmation: true, canSendCalVideoTranscriptionEmails: true, requiresBookerEmailVerification: true, diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index cdecff651f090c..32e9d2495e5581 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -671,6 +671,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit [ + "Host.userId", + jsonObjectFrom( + eb + .selectFrom("users") + .select(["users.id", "users.email"]) + .whereRef("Host.userId", "=", "users.id") + ).as("user"), + ]) + .whereRef("Host.eventTypeId", "=", "EventType.id") + ).as("hosts"), "EventType.length", jsonObjectFrom( eb @@ -640,10 +654,29 @@ export async function getBookings({ }) ); + const checkIfUserIsHost = (userId: number, booking: (typeof plainBookings)[number]) => { + if (booking.user?.id === userId) { + return true; + } + + if (!booking.eventType?.hosts || booking.eventType.hosts.length === 0) { + return false; + } + + const attendeeEmails = new Set(booking.attendees.map((attendee) => attendee.email)); + + return booking.eventType.hosts.some(({ user: hostUser }) => { + return hostUser?.id === userId && attendeeEmails.has(hostUser.email); + }); + }; const bookings = await Promise.all( plainBookings.map(async (booking) => { - // If seats are enabled and the event is not set to show attendees, filter out attendees that are not the current user - if (booking.seatsReferences.length && !booking.eventType?.seatsShowAttendees) { + // If seats are enabled, the event is not set to show attendees, and the current user is not the host, filter out attendees who are not the current user + if ( + booking.seatsReferences.length && + !booking.eventType?.seatsShowAttendees && + !checkIfUserIsHost(user.id, booking) + ) { booking.attendees = booking.attendees.filter((attendee) => attendee.email === user.email); } diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 2ace9298dbf51b..c22294501544c0 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -93,7 +93,8 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { if ( !isSystemAdmin && - (!hasMembership?.role || !(["ADMIN", "OWNER"].includes(hasMembership.role) || isOrgAdmin)) + !isOrgAdmin && + (!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role)) ) { console.warn(`User ${userId} does not have permission to create this new event type`); throw new TRPCError({ code: "UNAUTHORIZED" }); diff --git a/yarn.lock b/yarn.lock index 7a6dbca9284ba1..aae64608f91b95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,7 +2519,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3566,13 +3566,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.267": - version: 0.0.267 - resolution: "@calcom/platform-libraries@npm:0.0.267" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.268": + version: 0.0.268 + resolution: "@calcom/platform-libraries@npm:0.0.268" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 0c385514a04ddbd96ab3277774815233801a6eaf9a0d406b473eb7a446602f6f7d98de7f16747893b9fe1d50257db13b050a5726c8e7fe9500750d3ebf7be23a + checksum: d361c067dd33e3807c3ba965b3582eadaeec2ef1e2a3114c68e0c2ab1d7149fcca2c72ce00f8f3f341bdf3caeca578898d3345d8615c58da3a66e541e62d2ca5 languageName: node linkType: hard