Skip to content

Commit

Permalink
fix: v2 slots timeZone parameter (calcom#18488)
Browse files Browse the repository at this point in the history
* fix: v2 slots timeZone parameter

* test: v2 slots
  • Loading branch information
supalarry authored and MuhammadAimanSulaiman committed Feb 24, 2025
1 parent df4608c commit 8d4052f
Show file tree
Hide file tree
Showing 10 changed files with 638 additions and 66 deletions.
490 changes: 490 additions & 0 deletions apps/api/v2/src/modules/slots/controllers/slots.controller.e2e-spec.ts

Large diffs are not rendered by default.

25 changes: 13 additions & 12 deletions apps/api/v2/src/modules/slots/controllers/slots.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { SlotsOutputService } from "@/modules/slots/services/slots-output.service";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SlotFormat } from "@calcom/platform-enums";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
Expand All @@ -17,7 +17,10 @@ import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
})
@DocsTags("Slots")
export class SlotsController {
constructor(private readonly slotsService: SlotsService) {}
constructor(
private readonly slotsService: SlotsService,
private readonly slotsOutputService: SlotsOutputService
) {}

@Post("/reserve")
@ApiCreatedResponse({
Expand Down Expand Up @@ -159,19 +162,17 @@ export class SlotsController {
},
});

const transformedSlots =
query.slotFormat === SlotFormat.Range
? await this.slotsService.formatSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat
)
: availableSlots.slots;
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);

return {
data: {
slots: transformedSlots,
slots,
},
status: SUCCESS_STATUS,
};
Expand Down
96 changes: 96 additions & 0 deletions apps/api/v2/src/modules/slots/services/slots-output.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";

import { SlotFormat } from "@calcom/platform-enums";

type TimeSlots = { slots: Record<string, { time: string }[]> };
type RangeSlots = { slots: Record<string, { startTime: string; endTime: string }[]> };

@Injectable()
export class SlotsOutputService {
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {}

async getOutputSlots(
availableSlots: TimeSlots,
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat,
timeZone?: string
): Promise<TimeSlots | RangeSlots> {
if (!slotFormat) {
return timeZone ? this.setTimeZone(availableSlots, timeZone) : availableSlots;
}

const formattedSlots = await this.formatSlots(availableSlots, duration, eventTypeId, slotFormat);
return timeZone ? this.setTimeZoneRange(formattedSlots, timeZone) : formattedSlots;
}

private setTimeZone(slots: TimeSlots, timeZone: string): TimeSlots {
const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => {
acc[date] = daySlots.map((slot) => ({
time: DateTime.fromISO(slot.time).setZone(timeZone).toISO() || "unknown-time",
}));
return acc;
}, {} as Record<string, { time: string }[]>);

return { slots: formattedSlots };
}

private setTimeZoneRange(slots: RangeSlots, timeZone: string): RangeSlots {
const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => {
acc[date] = daySlots.map((slot) => ({
startTime: DateTime.fromISO(slot.startTime).setZone(timeZone).toISO() || "unknown-start-time",
endTime: DateTime.fromISO(slot.endTime).setZone(timeZone).toISO() || "unknown-end-time",
}));
return acc;
}, {} as Record<string, { startTime: string; endTime: string }[]>);

return { slots: formattedSlots };
}

private async formatSlots(
availableSlots: TimeSlots,
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<RangeSlots> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}

const slotDuration = await this.getDuration(duration, eventTypeId);

const slots = Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});

return { slots };
}

private async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}

if (eventTypeId) {
const eventType = await this.eventTypesRepository.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}

throw new Error("duration or eventTypeId is required");
}
}
48 changes: 1 addition & 47 deletions apps/api/v2/src/modules/slots/services/slots.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";
import { v4 as uuid } from "uuid";

import { SlotFormat } from "@calcom/platform-enums";
Expand Down Expand Up @@ -55,51 +56,4 @@ export class SlotsService {
const event = await this.eventTypeRepo.getEventTypeById(eventTypeId);
return !!event?.teamId;
}

async getEventTypeWithDuration(eventTypeId: number) {
return await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
}

async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}

if (eventTypeId) {
const eventType = await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}

throw new Error("duration or eventTypeId is required");
}

async formatSlots(
availableSlots: { slots: Record<string, { time: string }[]> },
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<Record<string, { startTime: string; endTime: string }[]>> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}

const slotDuration = await this.getDuration(duration, eventTypeId);

return Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});
}
}
3 changes: 2 additions & 1 deletion apps/api/v2/src/modules/slots/slots.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsController } from "@/modules/slots/controllers/slots.controller";
import { SlotsOutputService } from "@/modules/slots/services/slots-output.service";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Module } from "@nestjs/common";

@Module({
imports: [PrismaModule, EventTypesModule_2024_04_15],
providers: [SlotsRepository, SlotsService],
providers: [SlotsRepository, SlotsService, SlotsOutputService],
controllers: [SlotsController],
exports: [SlotsService],
})
Expand Down
5 changes: 3 additions & 2 deletions apps/api/v2/swagger/documentation.json
Original file line number Diff line number Diff line change
Expand Up @@ -5139,7 +5139,7 @@
"name": "eventTypeSlug",
"required": false,
"in": "query",
"description": "Slug of the event type for which slots are being fetched.",
"description": "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`",
"schema": {
"type": "string"
}
Expand All @@ -5148,7 +5148,8 @@
"name": "usernameList",
"required": false,
"in": "query",
"description": "Only for dynamic events - list of usernames for which slots are being fetched.",
"description": "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.",
"example": "usernameList[]=bob",
"schema": {
"type": "array",
"items": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export class EventTypesRepositoryFixture {
return this.prismaWriteClient.eventType.create({
data: {
...data,
users: {
connect: {
id: userId,
},
},
owner: {
connect: {
id: userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { SelectedSlots } from "@prisma/client";

export class SelectedSlotsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];

constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}

async deleteByUId(uid: SelectedSlots["uid"]) {
return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } });
}
}
5 changes: 3 additions & 2 deletions docs/api-reference/v2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4879,7 +4879,7 @@
"name": "eventTypeSlug",
"required": false,
"in": "query",
"description": "Slug of the event type for which slots are being fetched.",
"description": "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`",
"schema": {
"type": "string"
}
Expand All @@ -4888,7 +4888,8 @@
"name": "usernameList",
"required": false,
"in": "query",
"description": "Only for dynamic events - list of usernames for which slots are being fetched.",
"description": "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.",
"example": "usernameList[]=bob",
"schema": {
"type": "array",
"items": {
Expand Down
9 changes: 7 additions & 2 deletions packages/platform/types/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,20 @@ export class GetAvailableSlotsInput {

@IsString()
@IsOptional()
@ApiPropertyOptional({ description: "Slug of the event type for which slots are being fetched." })
@ApiPropertyOptional({
description:
"Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`",
})
eventTypeSlug?: string;

@IsArray()
@IsString({ each: true })
@IsOptional()
@ApiPropertyOptional({
type: [String],
description: "Only for dynamic events - list of usernames for which slots are being fetched.",
description:
"Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.",
example: "usernameList[]=bob",
})
usernameList?: string[];

Expand Down

0 comments on commit 8d4052f

Please sign in to comment.