feat: Troubleshooter atom (#27497)

This commit is contained in:
Rajiv Sahal authored and GitHub committed 2026-02-06 17:34:58 +02:00
1 parent a71d62dc33
commit 8a17ebc2ef
31 files changed
+821 -305

No files matched your search

@@ -1,3 +1,20 @@
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { listWithTeamHandler, PublicEventType } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Query,
UseGuards,
VERSION_NEUTRAL,
Version,
} from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController, ApiTags as DocsTags } from "@nestjs/swagger";
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
@@ -16,24 +33,6 @@ 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 { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Param,
ParseIntPipe,
UseGuards,
Version,
VERSION_NEUTRAL,
Patch,
Body,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { PublicEventType } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
/*
Event-types endpoints for atoms, split from AtomsController for clarity and maintainability.
@@ -49,6 +48,17 @@ These endpoints should not be recommended for use by third party and are exclude
export class AtomsEventTypesController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
@Get("/event-types/list-with-team")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listEventTypesWithTeam(@GetUser() user: UserWithProfile): Promise<ApiResponse<unknown>> {
const eventTypes = await listWithTeamHandler({ ctx: { user } });
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/event-types/:eventSlug/public")
async getPublicEventType(
@Param("eventSlug") eventSlug: string,
@@ -73,6 +73,21 @@ export class AtomsSchedulesController {
};
}
@Get("/schedules/event-type/:eventSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Permissions([SCHEDULE_READ])
async getScheduleByEventSlug(
@GetUser() user: UserWithProfile,
@Param("eventSlug") eventSlug: string
): Promise<ApiResponse<unknown>> {
const schedule = await this.schedulesService.getScheduleByEventSlug(user, eventSlug);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Get("/schedules/all")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -1,3 +1,4 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -5,6 +6,7 @@ import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ScheduleRepository, UpdateScheduleResponse } from "@calcom/platform-libraries/schedules";
import { getScheduleByEventSlugHandler } from "@calcom/platform-libraries/schedules";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { UpdateAtomScheduleDto } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -15,6 +17,7 @@ export class SchedulesAtomsService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
) {}
@@ -42,6 +45,13 @@ export class SchedulesAtomsService {
});
}
async getScheduleByEventSlug(user: UserWithProfile, eventSlug: string) {
return getScheduleByEventSlugHandler({
ctx: { user, prisma: this.dbRead.prisma as unknown as PrismaClient },
input: { eventSlug },
});
}
async updateUserSchedule({
input,
user,
@@ -3,7 +3,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
import { TroubleshooterListItemContainer } from "@calcom/features/troubleshooter/components/TroubleshooterListItemContainer";
function AvailabiltyItem() {
const { t } = useLocale();
@@ -1,124 +1,16 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CalendarToggleContainerComponent } from "@calcom/features/troubleshooter/components/CalendarToggleContainerComponent";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
const SELECTION_COLORS = ["#f97316", "#84cc16", "#06b6d4", "#8b5cf6", "#ec4899", "#f43f5e"];
interface CalendarToggleItemProps {
title: string;
subtitle: string;
colorDot?: string;
status: "connected" | "not_found";
calendars?: {
active?: boolean;
name?: string;
}[];
}
function CalendarToggleItem(props: CalendarToggleItemProps) {
const badgeStatus = props.status === "connected" ? "green" : "orange";
const badgeText = props.status === "connected" ? "Connected" : "Not found";
return (
<TroubleshooterListItemContainer
title={props.title}
subtitle={props.subtitle}
prefixSlot={
<>
<div
className="h-4 w-4 self-center rounded-[4px]"
style={{
backgroundColor: props.colorDot,
}}
/>
</>
}
suffixSlot={
<div>
<Badge variant={badgeStatus} withDot size="sm">
{badgeText}
</Badge>
</div>
}>
<div className="[&>*]:text-emphasis flex flex-col gap-3">
{props.calendars?.map((calendar) => {
return <Switch key={calendar.name} checked={calendar.active} label={calendar.name} disabled />;
})}
</div>
</TroubleshooterListItemContainer>
);
}
function EmptyCalendarToggleItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title={t("installed", { count: 0 })}
subtitle={t("please_install_a_calendar")}
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="orange" withDot size="sm">
{t("unavailable")}
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<Button color="secondary" className="justify-center gap-2" href="/apps/categories/calendar">
{t("install_calendar")}
</Button>
</div>
</TroubleshooterListItemContainer>
);
}
export function CalendarToggleContainer() {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.calendars.connectedCalendars.useQuery();
const hasConnectedCalendars = data && data?.connectedCalendars.length > 0;
const { data, isLoading } =
trpc.viewer.calendars.connectedCalendars.useQuery();
return (
<div className="flex flex-col stack-y-3">
<p className="text-sm font-medium leading-none">{t("calendars_were_checking_for_conflicts")}</p>
{hasConnectedCalendars && !isLoading ? (
<>
{data.connectedCalendars.map((calendar) => {
const foundPrimary = calendar.calendars?.find((item) => item.primary);
// Will be used when getAvailbility is modified to use externalId instead of appId for source.
// const color = SELECTION_COLORS[idx] || "#000000";
// // Add calendar to color map using externalId (what we use on the backend to determine source)
// addToColorMap(foundPrimary?.externalId, color);
return (
<CalendarToggleItem
key={calendar.credentialId}
title={calendar.integration.name}
colorDot="#000000"
subtitle={foundPrimary?.name ?? "Nameless Calendar"}
status={calendar.error ? "not_found" : "connected"}
calendars={calendar.calendars?.map((item) => {
return {
active: item.isSelected,
name: item.name,
};
})}
/>
);
})}
<Button color="secondary" className="justify-center gap-2" href="/settings/my-account/calendars">
{t("manage_calendars")}
</Button>
</>
) : (
<EmptyCalendarToggleItem />
)}
</div>
<CalendarToggleContainerComponent
connectedCalendars={data?.connectedCalendars ?? []}
isLoading={isLoading}
manageCalendarsAction={{ href: "/settings/my-account/calendars" }}
installCalendarAction={{ href: "/apps/categories/calendar" }}
/>
);
}
@@ -1,7 +1,7 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
import { TroubleshooterListItemHeader } from "@calcom/features/troubleshooter/components/TroubleshooterListItemContainer";
function ConnectedAppsItem() {
return (
@@ -1,43 +1,42 @@
import Link from "next/link";
import { EventScheduleItemComponent } from "@calcom/features/troubleshooter/components/EventScheduleItemComponent";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Label } from "@calcom/ui/components/form";
import Link from "next/link";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
export { EventScheduleItemComponent };
export function EventScheduleItem() {
export function EventScheduleItem(): JSX.Element {
const { t } = useLocale();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const { data: schedule } = trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
const { data: schedule } =
trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
return (
<div>
<Label>{t("availability_schedule")}</Label>
<TroubleshooterListItemHeader
className="group rounded-md border-b"
prefixSlot={<div className="w-4 rounded-[4px] bg-black" />}
title={schedule?.name ?? "Loading"}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge color="orange" size="sm" className="invisible hover:cursor-pointer group-hover:visible">
{t("edit")}
</Badge>
</Link>
)
}
/>
</div>
<EventScheduleItemComponent
schedule={schedule ?? null}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge
color="orange"
size="sm"
className="invisible hover:cursor-pointer group-hover:visible"
>
{t("edit")}
</Badge>
</Link>
)
}
/>
);
}
@@ -1,88 +1,16 @@
import { useMemo, useEffect, startTransition } from "react";
import { shallow } from "zustand/shallow";
import { EventTypeSelectComponent } from "@calcom/features/troubleshooter/components/EventTypeSelectComponent";
import { trpc } from "@calcom/trpc/react";
import { SelectField } from "@calcom/ui/components/form";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
export { EventTypeSelectComponent };
export function EventTypeSelect() {
const { data: eventTypes, isPending } = trpc.viewer.eventTypes.listWithTeam.useQuery();
const { event: selectedEventType, setEvent: setSelectedEventType } = useTroubleshooterStore(
(state) => ({
event: state.event,
setEvent: state.setEvent,
}),
shallow
);
const options = useMemo(() => {
if (!eventTypes) return [];
return eventTypes.map((e) => ({
label: e.title,
value: e.id.toString(),
id: e.id,
duration: e.length,
}));
}, [eventTypes]);
// Initialize event type from query param or default to first event
useEffect(() => {
if (!eventTypes || eventTypes.length === 0) return;
const selectedEventIdParam = getQueryParam("eventTypeId");
const eventTypeId = selectedEventIdParam ? parseInt(selectedEventIdParam, 10) : null;
// If we already have a selected event that matches the query param, don't do anything
if (selectedEventType?.id === eventTypeId) return;
// If there's a query param, try to find and set that event
if (eventTypeId && !isNaN(eventTypeId)) {
startTransition(() => {
const foundEventType = eventTypes.find((et) => et.id === eventTypeId);
if (foundEventType) {
setSelectedEventType({
id: foundEventType.id,
slug: foundEventType.slug,
duration: foundEventType.length,
teamId: foundEventType.team?.id ?? null,
});
return;
}
});
}
// If no event is selected and no valid query param, default to first event
if (!selectedEventType && !eventTypeId) {
const firstEvent = eventTypes[0];
setSelectedEventType({
id: firstEvent.id,
slug: firstEvent.slug,
duration: firstEvent.length,
teamId: firstEvent.team?.id ?? null,
});
}
}, [eventTypes, selectedEventType, setSelectedEventType]);
export function EventTypeSelect(): JSX.Element {
const { data: eventTypes, isPending } =
trpc.viewer.eventTypes.listWithTeam.useQuery();
return (
<SelectField
label="Event Type"
options={options}
isDisabled={isPending || options.length === 0}
value={options.find((option) => option.id === selectedEventType?.id) || options[0]}
onChange={(option) => {
if (!option) return;
const foundEventType = eventTypes?.find((et) => et.id === option.id);
if (foundEventType) {
setSelectedEventType({
id: foundEventType.id,
slug: foundEventType.slug,
duration: foundEventType.length,
teamId: foundEventType.team?.id ?? null,
});
}
}}
<EventTypeSelectComponent
eventTypes={eventTypes ?? []}
isPending={isPending}
/>
);
}
@@ -1,42 +0,0 @@
import type { PropsWithChildren } from "react";
import classNames from "@calcom/ui/classNames";
interface TroubleshooterListItemContainerProps {
title: string;
subtitle?: string;
suffixSlot?: React.ReactNode;
prefixSlot?: React.ReactNode;
className?: string;
}
export function TroubleshooterListItemHeader({
prefixSlot,
title,
subtitle,
suffixSlot,
className,
}: TroubleshooterListItemContainerProps) {
return (
<div className={classNames("border-subtle flex max-w-full gap-3 border border-b-0 px-4 py-2", className)}>
{prefixSlot}
<div className="flex h-full max-w-full flex-1 flex-col flex-nowrap truncate text-sm leading-4">
<p className="font-medium">{title}</p>
{subtitle && <p className="font-normal">{subtitle}</p>}
</div>
{suffixSlot}
</div>
);
}
export function TroubleshooterListItemContainer({
children,
...rest
}: PropsWithChildren<TroubleshooterListItemContainerProps>) {
return (
<div className="[&>*:first-child]:rounded-t-md ">
<TroubleshooterListItemHeader {...rest} />
<div className="border-subtle flex flex-col stack-y-3 rounded-b-md border p-4">{children}</div>
</div>
);
}
@@ -1247,6 +1247,7 @@
"invitees_can_schedule": "Invitees can schedule",
"date_range": "Date range",
"calendar_days": "calendar days",
"nameless_calendar": "Nameless Calendar",
"business_days": "business days",
"set_address_place": "Set an address or place",
"set_link_meeting": "Set a link to the meeting",
@@ -2925,6 +2926,7 @@
"directory_sync_delete_confirmation": "This action cannot be undone. This will permanently delete the directory sync connection.",
"event_setup_length_error": "Event Setup: The duration must be at least 1 minute.",
"availability_schedules": "Availability schedules",
"availability_schedule": "Availability Schedule",
"unauthorized": "Unauthorized",
"access_cal_account": "{{clientName}} wants to access your {{appName}} account",
"select_account_team": "Select account or team",