refactor: Extract schedule determination to seperate fn (#26600)
* refactor: Extract schedule determination to seperate fn * refactor: Extract schedule detection to seperate file and add tests * RouterOutputs should be used in hooks, not direct type imports
This commit is contained in:
1 parent
58798f27d2
commit
312733ce68
4 files changed
+345
-60
No files matched your search
@@ -2,17 +2,23 @@ import { useSession } from "next-auth/react";
|
||||
import { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability";
|
||||
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
type UseOnboardingCalendarEventsProps = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
const emptyAvailabilityData = {
|
||||
type UseOnboardingCalendarEventsResult = {
|
||||
events: CalendarEvent[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const emptyAvailabilityData: RouterOutputs["viewer"]["availability"]["user"] = {
|
||||
busy: [],
|
||||
timeZone: "",
|
||||
dateRanges: [],
|
||||
@@ -21,9 +27,12 @@ const emptyAvailabilityData = {
|
||||
dateOverrides: [],
|
||||
currentSeats: null,
|
||||
datesOutOfOffice: {},
|
||||
} as GetUserAvailabilityResult;
|
||||
};
|
||||
|
||||
export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => {
|
||||
export const useOnboardingCalendarEvents = ({
|
||||
startDate,
|
||||
endDate,
|
||||
}: UseOnboardingCalendarEventsProps): UseOnboardingCalendarEventsResult => {
|
||||
const { data: session } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -65,7 +74,6 @@ export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardin
|
||||
start: new Date(event.start),
|
||||
end: new Date(event.end),
|
||||
options: {
|
||||
color: event.source ? undefined : undefined,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_SCHEDULE_DATA, detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser";
|
||||
import type { DetectEventTypeScheduleForUserInput } from "./detectEventTypeScheduleForUser";
|
||||
|
||||
describe("detectEventTypeScheduleForUser", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
timeZone: "America/New_York",
|
||||
defaultScheduleId: 100,
|
||||
schedules: [
|
||||
{
|
||||
id: 100,
|
||||
timeZone: "America/New_York",
|
||||
availability: [],
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
timeZone: "Europe/London",
|
||||
availability: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createInput = (
|
||||
overrides: Partial<DetectEventTypeScheduleForUserInput> = {}
|
||||
): DetectEventTypeScheduleForUserInput => ({
|
||||
user: mockUser,
|
||||
eventType: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("should prioritize eventType.schedule if present", () => {
|
||||
const eventTypeSchedule = {
|
||||
id: 999,
|
||||
timeZone: "Asia/Tokyo",
|
||||
availability: [],
|
||||
};
|
||||
|
||||
const input = createInput({
|
||||
eventType: {
|
||||
hosts: [],
|
||||
timeZone: null,
|
||||
schedule: eventTypeSchedule,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
|
||||
expect(result.schedule).toEqual(expect.objectContaining({ id: 999, timeZone: "Asia/Tokyo" }));
|
||||
expect(result.isTimezoneSet).toBe(true);
|
||||
expect(result.isDefaultSchedule).toBe(false);
|
||||
});
|
||||
|
||||
it("should prioritize host schedule if eventType.schedule is missing", () => {
|
||||
const hostSchedule = {
|
||||
id: 888,
|
||||
timeZone: "Europe/Berlin",
|
||||
availability: [],
|
||||
};
|
||||
|
||||
const input = createInput({
|
||||
eventType: {
|
||||
hosts: [
|
||||
{
|
||||
user: { id: 1 },
|
||||
schedule: hostSchedule,
|
||||
},
|
||||
],
|
||||
timeZone: null,
|
||||
schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
|
||||
expect(result.schedule).toEqual(expect.objectContaining({ id: 888, timeZone: "Europe/Berlin" }));
|
||||
expect(result.isTimezoneSet).toBe(true);
|
||||
expect(result.isDefaultSchedule).toBe(false);
|
||||
});
|
||||
|
||||
it("should prioritize user default schedule if neither event nor host schedule is present", () => {
|
||||
const input = createInput({
|
||||
eventType: {
|
||||
hosts: [],
|
||||
timeZone: null,
|
||||
schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
|
||||
expect(result.schedule).toEqual(expect.objectContaining({ id: 100, timeZone: "America/New_York" }));
|
||||
expect(result.isTimezoneSet).toBe(true);
|
||||
// It matches the user's default schedule ID
|
||||
expect(result.isDefaultSchedule).toBe(true);
|
||||
});
|
||||
|
||||
it("should use user schedule if it acts as host schedule", () => {
|
||||
// This covers the case where host schedule is explicitly set and matches a user schedule
|
||||
const userSchedule = mockUser.schedules[1]; // id 101
|
||||
const input = createInput({
|
||||
eventType: {
|
||||
hosts: [
|
||||
{
|
||||
user: { id: 1 },
|
||||
schedule: userSchedule,
|
||||
},
|
||||
],
|
||||
timeZone: null,
|
||||
schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
expect(result.schedule).toEqual(expect.objectContaining({ id: 101 }));
|
||||
expect(result.isDefaultSchedule).toBe(false); // 101 is not default (100 is)
|
||||
});
|
||||
|
||||
it("should use fallback schedule if no schedules are found", () => {
|
||||
const userWithNoSchedules = {
|
||||
...mockUser,
|
||||
schedules: [],
|
||||
defaultScheduleId: null,
|
||||
};
|
||||
|
||||
const input = createInput({
|
||||
user: userWithNoSchedules,
|
||||
eventType: {
|
||||
hosts: [],
|
||||
timeZone: null,
|
||||
schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
|
||||
expect(result.schedule.id).toBe(0); // Default schedule ID
|
||||
expect(result.schedule.availability?.[0]?.days).toEqual(DEFAULT_SCHEDULE_DATA.availability?.[0]?.days);
|
||||
expect(result.isTimezoneSet).toBe(false); // Fallback schedule doesn't count as "set"
|
||||
expect(result.isDefaultSchedule).toBe(false);
|
||||
});
|
||||
|
||||
it("should use eventType timezone as fallback if schedule timezone is missing", () => {
|
||||
const userWithNoSchedules = {
|
||||
...mockUser,
|
||||
schedules: [],
|
||||
defaultScheduleId: null,
|
||||
};
|
||||
|
||||
const input = createInput({
|
||||
user: userWithNoSchedules,
|
||||
eventType: {
|
||||
hosts: [],
|
||||
timeZone: "Pacific/Honolulu",
|
||||
schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
|
||||
// Schedule itself (fallback) has no timezone, so it picks up the fallback
|
||||
expect(result.schedule.timeZone).toBe("Pacific/Honolulu");
|
||||
expect(result.isTimezoneSet).toBe(false);
|
||||
});
|
||||
|
||||
it("should use user timezone as fallback if everything else is missing", () => {
|
||||
const userWithNoSchedules = {
|
||||
...mockUser,
|
||||
schedules: [],
|
||||
defaultScheduleId: null,
|
||||
timeZone: "Africa/Cairo",
|
||||
};
|
||||
|
||||
const input = createInput({
|
||||
user: userWithNoSchedules,
|
||||
eventType: {
|
||||
hosts: [],
|
||||
timeZone: null,
|
||||
schedule: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectEventTypeScheduleForUser(input);
|
||||
|
||||
expect(result.schedule.timeZone).toBe("Africa/Cairo");
|
||||
expect(result.isTimezoneSet).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { GetUserAvailabilityInitialData } from "./getUserAvailability";
|
||||
|
||||
export type ScheduleWithoutTimeZone = {
|
||||
id: number;
|
||||
availability?: {
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
date: Date | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const DEFAULT_SCHEDULE_DATA: ScheduleWithoutTimeZone = {
|
||||
availability: [
|
||||
{
|
||||
startTime: new Date("1970-01-01T09:00:00Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00Z"),
|
||||
days: [1, 2, 3, 4, 5], // Monday to Friday
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
id: 0,
|
||||
};
|
||||
|
||||
export type DetectEventTypeScheduleForUserInput = {
|
||||
eventType?: {
|
||||
hosts: {
|
||||
user: {
|
||||
id: number;
|
||||
};
|
||||
schedule:
|
||||
| (ScheduleWithoutTimeZone & {
|
||||
timeZone: string | null;
|
||||
})
|
||||
| null;
|
||||
}[];
|
||||
timeZone: string | null;
|
||||
schedule:
|
||||
| (ScheduleWithoutTimeZone & {
|
||||
timeZone: string | null;
|
||||
})
|
||||
| null;
|
||||
} | null;
|
||||
user: {
|
||||
schedules: NonNullable<GetUserAvailabilityInitialData["user"]>["schedules"];
|
||||
defaultScheduleId: number | null;
|
||||
timeZone: string;
|
||||
id: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type DetectEventTypeScheduleForUserOutput = {
|
||||
isDefaultSchedule: boolean;
|
||||
isTimezoneSet: boolean;
|
||||
schedule: ScheduleWithoutTimeZone & {
|
||||
timeZone: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function detectEventTypeScheduleForUser({
|
||||
eventType,
|
||||
user,
|
||||
}: DetectEventTypeScheduleForUserInput): DetectEventTypeScheduleForUserOutput {
|
||||
const userSchedule = user.schedules.filter(
|
||||
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
||||
)[0];
|
||||
const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule;
|
||||
|
||||
// TODO: It uses default timezone of user. Should we use timezone of team ?
|
||||
const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone;
|
||||
|
||||
const fallbackSchedule = {
|
||||
...DEFAULT_SCHEDULE_DATA,
|
||||
timeZone: fallbackTimezoneIfScheduleIsMissing,
|
||||
};
|
||||
|
||||
let potentialSchedule = null;
|
||||
|
||||
if (eventType?.schedule) {
|
||||
potentialSchedule = eventType.schedule;
|
||||
} else if (hostSchedule) {
|
||||
potentialSchedule = hostSchedule;
|
||||
} else if (userSchedule) {
|
||||
potentialSchedule = userSchedule;
|
||||
}
|
||||
|
||||
const schedule = potentialSchedule ?? fallbackSchedule;
|
||||
|
||||
const isDefaultSchedule = !!(userSchedule && userSchedule.id === schedule?.id);
|
||||
|
||||
const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null);
|
||||
|
||||
return {
|
||||
isDefaultSchedule,
|
||||
isTimezoneSet,
|
||||
schedule: {
|
||||
...schedule,
|
||||
timeZone: schedule.timeZone || fallbackTimezoneIfScheduleIsMissing,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/ev
|
||||
import type { PrismaHolidayRepository } from "@calcom/features/holidays/repositories/PrismaHolidayRepository";
|
||||
import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository";
|
||||
import type { IRedisService } from "@calcom/features/redis/IRedisService";
|
||||
import type { WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule";
|
||||
import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges";
|
||||
import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-ranges";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import { stringToDayjsZod } from "@calcom/lib/dayjs";
|
||||
import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getHolidayService } from "@calcom/lib/holidays";
|
||||
import { getHolidayEmoji } from "@calcom/lib/holidays/getHolidayEmoji";
|
||||
@@ -169,9 +171,30 @@ export type CurrentSeats = Awaited<
|
||||
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]>
|
||||
>;
|
||||
|
||||
export type GetUserAvailabilityResult = Awaited<
|
||||
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUserAvailability"]>
|
||||
>;
|
||||
export type GetUserAvailabilityResult = {
|
||||
busy: EventBusyDetails[];
|
||||
timeZone: string;
|
||||
dateRanges: {
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
}[];
|
||||
oooExcludedDateRanges: {
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
}[];
|
||||
workingHours: WorkingHoursWithUserId[];
|
||||
dateOverrides: TimeRange[];
|
||||
currentSeats:
|
||||
| {
|
||||
uid: string;
|
||||
startTime: Date;
|
||||
_count: {
|
||||
attendees: number;
|
||||
};
|
||||
}[]
|
||||
| null;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
};
|
||||
|
||||
export interface IFromUser {
|
||||
id: number;
|
||||
@@ -323,7 +346,7 @@ export class UserAvailabilityService {
|
||||
async _getUserAvailability(
|
||||
params: GetUserAvailabilityParams,
|
||||
initialData?: GetUserAvailabilityInitialData
|
||||
) {
|
||||
): Promise<GetUserAvailabilityResult> {
|
||||
const {
|
||||
username,
|
||||
userId,
|
||||
@@ -360,38 +383,10 @@ export class UserAvailabilityService {
|
||||
currentSeats = await this.getCurrentSeats(eventType, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
const userSchedule = user.schedules.filter(
|
||||
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
|
||||
)[0];
|
||||
|
||||
const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule;
|
||||
|
||||
// TODO: It uses default timezone of user. Should we use timezone of team ?
|
||||
const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone;
|
||||
|
||||
const fallbackSchedule = {
|
||||
availability: [
|
||||
{
|
||||
startTime: new Date("1970-01-01T09:00:00Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00Z"),
|
||||
days: [1, 2, 3, 4, 5], // Monday to Friday
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
id: 0,
|
||||
|
||||
timeZone: fallbackTimezoneIfScheduleIsMissing,
|
||||
};
|
||||
|
||||
// possible timezones that have been set by or for a user
|
||||
const potentialSchedule = eventType?.schedule
|
||||
? eventType.schedule
|
||||
: hostSchedule
|
||||
? hostSchedule
|
||||
: userSchedule;
|
||||
|
||||
// if no schedules set by or for a user, use fallbackSchedule
|
||||
const schedule = potentialSchedule ?? fallbackSchedule;
|
||||
const { isDefaultSchedule, isTimezoneSet, schedule } = detectEventTypeScheduleForUser({
|
||||
eventType,
|
||||
user,
|
||||
});
|
||||
|
||||
const bookingLimits =
|
||||
eventType?.bookingLimits &&
|
||||
@@ -415,16 +410,19 @@ export class UserAvailabilityService {
|
||||
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
|
||||
: user.userLevelSelectedCalendars;
|
||||
|
||||
const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null);
|
||||
let calendarTimezone: string | null = null;
|
||||
let finalTimezone: string | null = null;
|
||||
|
||||
// this timezone is synced with google/outlook calendars timezone usingg delegated credentials
|
||||
// it's a fallback for delegated credentials users who want to sync their timezone with third party calendars
|
||||
const calendarTimezone = !isTimezoneSet ? await this.getTimezoneFromDelegatedCalendars(user) : null;
|
||||
if (!isTimezoneSet) {
|
||||
calendarTimezone = await this.getTimezoneFromDelegatedCalendars(user);
|
||||
if (calendarTimezone) {
|
||||
finalTimezone = calendarTimezone;
|
||||
}
|
||||
}
|
||||
|
||||
const finalTimezone =
|
||||
!isTimezoneSet && calendarTimezone
|
||||
? calendarTimezone
|
||||
: schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing;
|
||||
if (!finalTimezone) {
|
||||
finalTimezone = schedule.timeZone;
|
||||
}
|
||||
|
||||
let busyTimesFromLimits: EventBusyDetails[] = [];
|
||||
|
||||
@@ -518,17 +516,6 @@ export class UserAvailabilityService {
|
||||
...busyTimesFromTeamLimits,
|
||||
];
|
||||
|
||||
const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id;
|
||||
|
||||
log.debug(
|
||||
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({
|
||||
chosenSchedule: schedule,
|
||||
eventTypeSchedule: eventType?.schedule,
|
||||
userSchedule: userSchedule,
|
||||
hostSchedule: hostSchedule,
|
||||
})}`
|
||||
);
|
||||
|
||||
if (
|
||||
!(
|
||||
schedule?.availability ||
|
||||
|
||||
Reference in new issue
Block a user