diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts index 191772ec5947b..92bbafcf6f3c5 100644 --- a/apps/api/v2/src/ee/platform-endpoints-module.ts +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -1,10 +1,11 @@ import { GcalModule } from "@/ee/gcal/gcal.module"; import { ProviderModule } from "@/ee/provider/provider.module"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; @Module({ - imports: [GcalModule, ProviderModule], + imports: [GcalModule, ProviderModule, SchedulesModule], }) export class PlatformEndpointsModule implements NestModule { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts new file mode 100644 index 0000000000000..d63d69127ae6e --- /dev/null +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts @@ -0,0 +1,329 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { ScheduleResponse } from "@/ee/schedules/zod/response/response"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Schedules Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = "schedules-controller-e2e@api.com"; + let user: User; + + let createdSchedule: ScheduleResponse; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule], + providers: [SchedulesRepository, SchedulesService], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a default schedule", async () => { + const scheduleName = "schedule-name"; + const scheduleTimeZone = "Europe/Rome"; + + const body = { + name: scheduleName, + timeZone: scheduleTimeZone, + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse<{ schedule: ScheduleResponse }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedule).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(user.id); + expect(responseBody.data.schedule.name).toEqual(scheduleName); + expect(responseBody.data.schedule.timeZone).toEqual(scheduleTimeZone); + + expect(responseBody.data.schedule.availability).toBeDefined(); + expect(responseBody.data.schedule.availability?.length).toEqual(1); + const defaultAvailabilityDays = [1, 2, 3, 4, 5]; + const defaultAvailabilityStartTime = "09:00:00"; + const defaultAvailabilityEndTime = "17:00:00"; + + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual( + defaultAvailabilityStartTime + ); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + + const scheduleUser = await userRepositoryFixture.get(responseBody.data.schedule.userId); + expect(scheduleUser?.defaultScheduleId).toEqual(responseBody.data.schedule.id); + await scheduleRepositoryFixture.deleteById(responseBody.data.schedule.id); + await scheduleRepositoryFixture.deleteAvailabilities(responseBody.data.schedule.id); + }); + }); + + it("should create a schedule", async () => { + const scheduleName = "schedule-name"; + const scheduleTimeZone = "Europe/Rome"; + const availabilityDays = [1, 2, 3, 4, 5, 6]; + const availabilityStartTime = "11:00:00"; + const availabilityEndTime = "14:00:00"; + + const body = { + name: scheduleName, + timeZone: scheduleTimeZone, + availabilities: [ + { + days: availabilityDays, + startTime: availabilityStartTime, + endTime: availabilityEndTime, + }, + ], + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse<{ schedule: ScheduleResponse }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedule).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(user.id); + expect(responseBody.data.schedule.name).toEqual(scheduleName); + expect(responseBody.data.schedule.timeZone).toEqual(scheduleTimeZone); + + expect(responseBody.data.schedule.availability).toBeDefined(); + expect(responseBody.data.schedule.availability?.length).toEqual(1); + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual(availabilityDays); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual(availabilityStartTime); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual(availabilityEndTime); + + createdSchedule = responseBody.data.schedule; + + const scheduleUser = await userRepositoryFixture.get(responseBody.data.schedule.userId); + expect(scheduleUser?.defaultScheduleId).toEqual(responseBody.data.schedule.id); + }); + }); + + it("should get default schedule", async () => { + return request(app.getHttpServer()) + .get("/api/v2/schedules/default") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse<{ schedule: ScheduleResponse }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedule).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.schedule.name).toEqual(createdSchedule.name); + expect(responseBody.data.schedule.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.schedule.availability).toBeDefined(); + expect(responseBody.data.schedule.availability?.length).toEqual(1); + + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual( + createdSchedule.availability?.[0]?.days + ); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + }); + }); + + it("should get schedule", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules/${createdSchedule.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse<{ schedule: ScheduleResponse }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedule).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.schedule.name).toEqual(createdSchedule.name); + expect(responseBody.data.schedule.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.schedule.availability).toBeDefined(); + expect(responseBody.data.schedule.availability?.length).toEqual(1); + + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual( + createdSchedule.availability?.[0]?.days + ); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + }); + }); + + it("should get schedules", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse<{ schedules: ScheduleResponse[] }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedules).toBeDefined(); + expect(responseBody.data.schedules.length).toEqual(1); + + const fetchedSchedule = responseBody.data.schedules[0]; + expect(fetchedSchedule).toBeDefined(); + expect(fetchedSchedule.userId).toEqual(createdSchedule.userId); + expect(fetchedSchedule.name).toEqual(createdSchedule.name); + expect(fetchedSchedule.timeZone).toEqual(createdSchedule.timeZone); + + expect(fetchedSchedule.availability).toBeDefined(); + expect(fetchedSchedule.availability?.length).toEqual(1); + + expect(fetchedSchedule.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days); + expect(fetchedSchedule.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(fetchedSchedule.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + }); + }); + + it("should update schedule name", async () => { + const newScheduleName = "new-schedule-name"; + + const body = { + name: newScheduleName, + }; + + return request(app.getHttpServer()) + .put(`/api/v2/schedules/${createdSchedule.id}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse<{ schedule: ScheduleResponse }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedule).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.schedule.name).toEqual(newScheduleName); + expect(responseBody.data.schedule.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.schedule.availability).toBeDefined(); + expect(responseBody.data.schedule.availability?.length).toEqual(1); + + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual( + createdSchedule.availability?.[0]?.days + ); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + + createdSchedule = responseBody.data.schedule; + }); + }); + + it("should update schedule availabilities", async () => { + const newAvailabilityDays = [2, 4]; + const newAvailabilityStartTime = "19:00:00"; + const newAvailabilityEndTime = "20:00:00"; + + const body = { + availabilities: [ + { + days: newAvailabilityDays, + startTime: newAvailabilityStartTime, + endTime: newAvailabilityEndTime, + }, + ], + }; + + return request(app.getHttpServer()) + .put(`/api/v2/schedules/${createdSchedule.id}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse<{ schedule: ScheduleResponse }> = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + expect(responseBody.data.schedule).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.schedule.name).toEqual(createdSchedule.name); + expect(responseBody.data.schedule.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.schedule.availability).toBeDefined(); + expect(responseBody.data.schedule.availability?.length).toEqual(1); + + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual(newAvailabilityDays); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual(newAvailabilityStartTime); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual(newAvailabilityEndTime); + + createdSchedule = responseBody.data.schedule; + }); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts new file mode 100644 index 0000000000000..8eb0ded1598ba --- /dev/null +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts @@ -0,0 +1,121 @@ +import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { ScheduleResponse, schemaScheduleResponse } from "@/ee/schedules/zod/response/response"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + UseGuards, +} from "@nestjs/common"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiResponse } from "@calcom/platform-types"; + +import { CreateScheduleInput } from "../inputs/create-schedule.input"; + +@Controller({ + path: "schedules", + version: "2", +}) +@UseGuards(AccessTokenGuard) +export class SchedulesController { + constructor(private readonly schedulesService: SchedulesService) {} + + @Post("/") + async createSchedule( + @GetUser("id") userId: number, + @Body() bodySchedule: CreateScheduleInput + ): Promise> { + const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule); + const scheduleResponse = schemaScheduleResponse.parse(schedule); + + return { + status: SUCCESS_STATUS, + data: { + schedule: scheduleResponse, + }, + }; + } + + @Get("/default") + async getDefaultSchedule( + @GetUser("id") userId: number + ): Promise> { + const schedule = await this.schedulesService.getUserScheduleDefault(userId); + const scheduleResponse = schemaScheduleResponse.parse(schedule); + + return { + status: SUCCESS_STATUS, + data: { + schedule: scheduleResponse, + }, + }; + } + + @Get("/:scheduleId") + async getSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number + ): Promise> { + const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId); + const scheduleResponse = schemaScheduleResponse.parse(schedule); + + return { + status: SUCCESS_STATUS, + data: { + schedule: scheduleResponse, + }, + }; + } + + @Get("/") + async getSchedules(@GetUser("id") userId: number): Promise> { + const schedules = await this.schedulesService.getUserSchedules(userId); + const schedulesResponse = schedules.map((schedule) => schemaScheduleResponse.parse(schedule)); + + return { + status: SUCCESS_STATUS, + data: { + schedules: schedulesResponse, + }, + }; + } + + @Put("/:scheduleId") + async updateSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number, + @Body() bodySchedule: UpdateScheduleInput + ): Promise> { + const schedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule); + const scheduleResponse = schemaScheduleResponse.parse(schedule); + + return { + status: SUCCESS_STATUS, + data: { + schedule: scheduleResponse, + }, + }; + } + + @Delete("/:scheduleId") + @HttpCode(HttpStatus.OK) + async deleteSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number + ): Promise { + await this.schedulesService.deleteUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts b/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts new file mode 100644 index 0000000000000..d77a2cf02d2bd --- /dev/null +++ b/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts @@ -0,0 +1,19 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Type } from "class-transformer"; +import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { TimeZone } from "@calcom/platform-constants"; + +export class CreateScheduleInput { + @IsString() + name!: string; + + @IsString() + timeZone!: TimeZone; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateAvailabilityInput) + @IsOptional() + availabilities?: CreateAvailabilityInput[]; +} diff --git a/apps/api/v2/src/ee/schedules/inputs/update-schedule.input.ts b/apps/api/v2/src/ee/schedules/inputs/update-schedule.input.ts new file mode 100644 index 0000000000000..0ee26231de9d6 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/inputs/update-schedule.input.ts @@ -0,0 +1,20 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Type } from "class-transformer"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; + +import { TimeZone } from "@calcom/platform-constants"; + +export class UpdateScheduleInput { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + timeZone?: TimeZone; + + @ValidateNested({ each: true }) + @Type(() => CreateAvailabilityInput) + @IsOptional() + availabilities?: CreateAvailabilityInput[]; +} diff --git a/apps/api/v2/src/ee/schedules/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules.module.ts new file mode 100644 index 0000000000000..626d41aaff34e --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules.module.ts @@ -0,0 +1,14 @@ +import { SchedulesController } from "@/ee/schedules/controllers/schedules.controller"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, AvailabilitiesModule, UsersModule], + providers: [SchedulesRepository, SchedulesService], + controllers: [SchedulesController], +}) +export class SchedulesModule {} diff --git a/apps/api/v2/src/ee/schedules/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules.repository.ts new file mode 100644 index 0000000000000..a9d3bf000cc1b --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules.repository.ts @@ -0,0 +1,133 @@ +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input"; +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createScheduleWithAvailabilities( + userId: number, + schedule: CreateScheduleInput, + availabilities: CreateAvailabilityInput[] + ) { + const createScheduleData: Prisma.ScheduleCreateInput = { + user: { + connect: { + id: userId, + }, + }, + name: schedule.name, + timeZone: schedule.timeZone, + }; + + if (availabilities.length > 0) { + createScheduleData.availability = { + createMany: { + data: availabilities.map((availability) => { + return { + days: availability.days, + startTime: availability.startTime, + endTime: availability.endTime, + userId, + }; + }), + }, + }; + } + + const createdSchedule = await this.dbWrite.prisma.schedule.create({ + data: { + ...createScheduleData, + }, + include: { + availability: true, + }, + }); + + return createdSchedule; + } + + async getScheduleById(scheduleId: number) { + const schedule = await this.dbRead.prisma.schedule.findUnique({ + where: { + id: scheduleId, + }, + include: { + availability: true, + }, + }); + + return schedule; + } + + async getSchedulesByUserId(userId: number) { + const schedules = await this.dbRead.prisma.schedule.findMany({ + where: { + userId, + }, + include: { + availability: true, + }, + }); + + return schedules; + } + + async updateScheduleWithAvailabilities(scheduleId: number, schedule: UpdateScheduleInput) { + const existingSchedule = await this.dbRead.prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} not found`); + } + + const updatedScheduleData: Prisma.ScheduleUpdateInput = { + user: { + connect: { + id: existingSchedule.userId, + }, + }, + }; + if (schedule.name) updatedScheduleData.name = schedule.name; + if (schedule.timeZone) updatedScheduleData.timeZone = schedule.timeZone; + + if (schedule.availabilities && schedule.availabilities.length > 0) { + await this.dbWrite.prisma.availability.deleteMany({ + where: { scheduleId }, + }); + + updatedScheduleData.availability = { + createMany: { + data: schedule.availabilities.map((availability) => ({ + ...availability, + userId: existingSchedule.userId, + })), + }, + }; + } + + const updatedSchedule = await this.dbWrite.prisma.schedule.update({ + where: { id: scheduleId }, + data: updatedScheduleData, + include: { + availability: true, + }, + }); + + return updatedSchedule; + } + + async deleteScheduleById(scheduleId: number) { + return this.dbWrite.prisma.schedule.delete({ + where: { + id: scheduleId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/schedules/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/services/schedules.service.ts new file mode 100644 index 0000000000000..a46f8b5d90381 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/services/schedules.service.ts @@ -0,0 +1,91 @@ +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Schedule } from "@prisma/client"; + +@Injectable() +export class SchedulesService { + constructor( + private readonly schedulesRepository: SchedulesRepository, + private readonly availabilitiesService: AvailabilitiesService, + private readonly usersRepository: UsersRepository + ) {} + + async createUserSchedule(userId: number, schedule: CreateScheduleInput) { + const availabilities = schedule.availabilities?.length + ? schedule.availabilities + : [this.availabilitiesService.getDefaultAvailabilityInput()]; + + const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities( + userId, + schedule, + availabilities + ); + + await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id); + + return createdSchedule; + } + + async getUserScheduleDefault(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.defaultScheduleId) return null; + + return this.schedulesRepository.getScheduleById(user.defaultScheduleId); + } + + async getUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return existingSchedule; + } + + async getUserSchedules(userId: number) { + return this.schedulesRepository.getSchedulesByUserId(userId); + } + + async updateUserSchedule(userId: number, scheduleId: number, schedule: UpdateScheduleInput) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + const updatedSchedule = await this.schedulesRepository.updateScheduleWithAvailabilities( + scheduleId, + schedule + ); + + return updatedSchedule; + } + + async deleteUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return this.schedulesRepository.deleteScheduleById(scheduleId); + } + + checkUserOwnsSchedule(userId: number, schedule: Pick) { + if (userId !== schedule.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`); + } + } +} diff --git a/apps/api/v2/src/ee/schedules/zod/response/response.ts b/apps/api/v2/src/ee/schedules/zod/response/response.ts new file mode 100644 index 0000000000000..27050d2db2791 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/zod/response/response.ts @@ -0,0 +1,36 @@ +import { DateTime } from "luxon"; +import { z } from "zod"; + +const scheduleSchema = z.object({ + id: z.number().int(), + userId: z.number().int(), + name: z.string(), + timeZone: z.string().nullish(), +}); + +const availabilitySchema = z.object({ + id: z.number().int(), + days: z.number().int().array(), + startTime: z.date(), + endTime: z.date(), +}); + +export const schemaScheduleResponse = z + .object({}) + .merge(scheduleSchema) + .merge( + z.object({ + availability: z + .array(availabilitySchema) + .transform((availabilities) => + availabilities.map((availability) => ({ + ...availability, + startTime: DateTime.fromJSDate(availability.startTime).toUTC().toFormat("HH:mm:ss"), + endTime: DateTime.fromJSDate(availability.endTime).toUTC().toFormat("HH:mm:ss"), + })) + ) + .optional(), + }) + ); + +export type ScheduleResponse = z.infer; diff --git a/apps/api/v2/src/lib/passport/strategies/types.ts b/apps/api/v2/src/lib/passport/strategies/types.ts new file mode 100644 index 0000000000000..4f9667397e899 --- /dev/null +++ b/apps/api/v2/src/lib/passport/strategies/types.ts @@ -0,0 +1,4 @@ +export class BaseStrategy { + success!: (user: unknown) => void; + error!: (error: Error) => void; +} diff --git a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts index c4018409e2781..a072a0eac9f4a 100644 --- a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts @@ -1,3 +1,4 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersRepository } from "@/modules/users/users.repository"; @@ -7,11 +8,6 @@ import type { Request } from "express"; import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; -class BaseStrategy { - success!: (user: unknown) => void; - error!: (error: Error) => void; -} - @Injectable() export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-token") { constructor( diff --git a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts index 69a843fd07a22..40c7f1c970ca0 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts @@ -1,14 +1,10 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; import { ApiKeyService } from "@/modules/api-key/api-key.service"; import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; -class BaseStrategy { - success!: (user: unknown) => void; - error!: (error: Error) => void; -} - @Injectable() export class ApiKeyAuthStrategy extends PassportStrategy(BaseStrategy, "api-key") { constructor( diff --git a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts index 117e8c2c713eb..1827e59e9093b 100644 --- a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts @@ -1,3 +1,4 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -5,11 +6,6 @@ import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; import { getToken } from "next-auth/jwt"; -class BaseStrategy { - success!: (user: unknown) => void; - error!: (error: Error) => void; -} - @Injectable() export class NextAuthStrategy extends PassportStrategy(BaseStrategy, "next-auth") { constructor(private readonly userRepository: UsersRepository, private readonly config: ConfigService) { diff --git a/apps/api/v2/src/modules/availabilities/availabilities.module.ts b/apps/api/v2/src/modules/availabilities/availabilities.module.ts new file mode 100644 index 0000000000000..f3fe35bf6a731 --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/availabilities.module.ts @@ -0,0 +1,10 @@ +import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [AvailabilitiesService], + exports: [AvailabilitiesService], +}) +export class AvailabilitiesModule {} diff --git a/apps/api/v2/src/modules/availabilities/availabilities.service.ts b/apps/api/v2/src/modules/availabilities/availabilities.service.ts new file mode 100644 index 0000000000000..fc7dc14ec494c --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/availabilities.service.ts @@ -0,0 +1,16 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class AvailabilitiesService { + getDefaultAvailabilityInput(): CreateAvailabilityInput { + const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0)); + const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0)); + + return { + days: [1, 2, 3, 4, 5], + startTime, + endTime, + }; + } +} diff --git a/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts b/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts new file mode 100644 index 0000000000000..a3050ae334c16 --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts @@ -0,0 +1,41 @@ +import { BadRequestException } from "@nestjs/common"; +import { Transform, TransformFnParams } from "class-transformer"; +import { IsArray, IsDate, IsNumber } from "class-validator"; + +export class CreateAvailabilityInput { + @IsArray() + @IsNumber({}, { each: true }) + days!: number[]; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + startTime!: Date; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + endTime!: Date; +} + +function transformStringToDate(value: string, key: string): Date { + const parts = value.split(":"); + + if (parts.length !== 3) { + throw new BadRequestException(`Invalid ${key} format. Expected format: HH:MM:SS. Received ${value}`); + } + + const [hours, minutes, seconds] = parts.map(Number); + + if (hours < 0 || hours > 23) { + throw new BadRequestException(`Invalid ${key} hours. Expected value between 0 and 23`); + } + + if (minutes < 0 || minutes > 59) { + throw new BadRequestException(`Invalid ${key} minutes. Expected value between 0 and 59`); + } + + if (seconds < 0 || seconds > 59) { + throw new BadRequestException(`Invalid ${key} seconds. Expected value between 0 and 59`); + } + + return new Date(new Date().setUTCHours(hours, minutes, seconds, 0)); +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts index cf96a4d72c058..8aecd3dd06586 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -66,8 +66,8 @@ export class OAuthClientUsersController { id: user.id, email: user.email, }, - accessToken: accessToken, - refreshToken: refreshToken, + accessToken, + refreshToken, }, }; } diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts index 422a97442b540..285f866125805 100644 --- a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts @@ -86,6 +86,7 @@ describe("OAuthClientCredentialsGuard", () => { getRequest: () => ({ headers, params, + get: (headerName: string) => headers[headerName], }), }), }); diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 055aa1f203335..9a68643ecc2f2 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -73,4 +73,13 @@ export class UsersRepository { return sanitizedUser; } + + setDefaultSchedule(userId: number, scheduleId: number) { + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + defaultScheduleId: scheduleId, + }, + }); + } } diff --git a/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts new file mode 100644 index 0000000000000..566ee5c0c8b46 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts @@ -0,0 +1,22 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Schedule } from "@prisma/client"; + +export class SchedulesRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async deleteById(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.schedule.delete({ where: { id: scheduleId } }); + } + + async deleteAvailabilities(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.availability.deleteMany({ where: { scheduleId } }); + } +} diff --git a/apps/api/v2/test/mocks/access-token-mock.strategy.ts b/apps/api/v2/test/mocks/access-token-mock.strategy.ts new file mode 100644 index 0000000000000..f7c89a2beb8ee --- /dev/null +++ b/apps/api/v2/test/mocks/access-token-mock.strategy.ts @@ -0,0 +1,25 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class AccessTokenMockStrategy extends PassportStrategy(BaseStrategy, "access-token") { + constructor(private readonly email: string, private readonly usersRepository: UsersRepository) { + super(); + } + + async authenticate() { + try { + const user = await this.usersRepository.findByEmail(this.email); + if (!user) { + throw new Error("User with the provided ID not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/mocks/next-auth-mock.strategy.ts b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts index 0bdb553ee4a24..96e92b00a8a42 100644 --- a/apps/api/v2/test/mocks/next-auth-mock.strategy.ts +++ b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts @@ -1,12 +1,8 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; -class BaseStrategy { - success!: (user: unknown) => void; - error!: (error: Error) => void; -} - @Injectable() export class NextAuthMockStrategy extends PassportStrategy(BaseStrategy, "next-auth") { constructor(private readonly email: string, private readonly userRepository: UsersRepository) { diff --git a/apps/api/v2/test/utils/withAccessTokenAuth.ts b/apps/api/v2/test/utils/withAccessTokenAuth.ts new file mode 100644 index 0000000000000..809de3c683b87 --- /dev/null +++ b/apps/api/v2/test/utils/withAccessTokenAuth.ts @@ -0,0 +1,10 @@ +import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { AccessTokenMockStrategy } from "test/mocks/access-token-mock.strategy"; + +export const withAccessTokenAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(AccessTokenStrategy).useFactory({ + factory: (usersRepository: UsersRepository) => new AccessTokenMockStrategy(email, usersRepository), + inject: [UsersRepository], + }); diff --git a/packages/platform/constants/index.ts b/packages/platform/constants/index.ts index 89dff9d4ed524..73019e2ee6651 100644 --- a/packages/platform/constants/index.ts +++ b/packages/platform/constants/index.ts @@ -1,3 +1,4 @@ export * from "./permissions"; export * from "./api"; +export * from "./timezones"; export * from "./apps"; diff --git a/packages/platform/constants/timezones.ts b/packages/platform/constants/timezones.ts new file mode 100644 index 0000000000000..126ff6ef7eb58 --- /dev/null +++ b/packages/platform/constants/timezones.ts @@ -0,0 +1,337 @@ +export type TimeZone = (typeof TIMEZONES)[number]; + +const TIMEZONES = [ + "Asia/Kabul", + "Europe/Tirane", + "Africa/Algiers", + "Asia/Beirut", + "Asia/Tehran", + "Pacific/Pago_Pago", + "Europe/Andorra", + "Africa/Luanda", + "Africa/Maputo", + "America/Santiago", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Catamarca", + "America/Argentina/Mendoza", + "America/Argentina/Salta", + "America/Argentina/Buenos_Aires", + "America/Argentina/San_Luis", + "America/Chicago", + "America/Los_Angeles", + "America/Argentina/Cordoba", + "America/Argentina/Jujuy", + "America/Argentina/La_Rioja", + "America/Argentina/Tucuman", + "America/Asuncion", + "America/Vancouver", + "America/Argentina/San_Juan", + "America/La_Paz", + "America/Sao_Paulo", + "America/Argentina/Ushuaia", + "America/Puerto_Rico", + "Asia/Yerevan", + "Australia/Sydney", + "Australia/Brisbane", + "Australia/Darwin", + "Australia/Perth", + "Australia/Melbourne", + "America/New_York", + "Australia/Adelaide", + "Europe/London", + "America/Toronto", + "Australia/Hobart", + "America/Phoenix", + "Africa/Johannesburg", + "America/Jamaica", + "America/Guyana", + "Australia/Broken_Hill", + "Europe/Vienna", + "Europe/Berlin", + "Asia/Baku", + "Asia/Dhaka", + "America/Barbados", + "Europe/Minsk", + "Europe/Brussels", + "America/Belize", + "Africa/Porto-Novo", + "Asia/Thimphu", + "America/Caracas", + "America/Hermosillo", + "America/Lima", + "America/Rio_Branco", + "Europe/Sarajevo", + "Africa/Gaborone", + "America/Fortaleza", + "America/Belem", + "America/Porto_Velho", + "America/Campo_Grande", + "America/Cuiaba", + "America/Araguaina", + "America/Recife", + "America/Manaus", + "Europe/Lisbon", + "America/Santarem", + "America/Boa_Vista", + "America/Mexico_City", + "America/Eirunepe", + "Europe/Istanbul", + "Asia/Brunei", + "Europe/Sofia", + "Africa/Ouagadougou", + "Africa/Bujumbura", + "Asia/Phnom_Penh", + "Africa/Douala", + "America/Winnipeg", + "America/Regina", + "America/Edmonton", + "America/Creston", + "America/Iqaluit", + "America/Yellowknife", + "America/Montreal", + "America/Blanc-Sablon", + "America/Halifax", + "America/St_Johns", + "America/Goose_Bay", + "America/Dawson_Creek", + "Pacific/Auckland", + "America/Coral_Harbour", + "America/Rankin_Inlet", + "America/Whitehorse", + "America/Nipigon", + "America/Atikokan", + "America/Moncton", + "America/Detroit", + "America/Pangnirtung", + "America/Fort_Nelson", + "America/Cambridge_Bay", + "America/Inuvik", + "America/Dawson", + "America/Resolute", + "America/Thunder_Bay", + "Atlantic/Cape_Verde", + "Asia/Kuala_Lumpur", + "Africa/Bangui", + "Africa/Ndjamena", + "America/Santo_Domingo", + "America/El_Salvador", + "America/Port_of_Spain", + "Asia/Urumqi", + "Asia/Chongqing", + "Asia/Shanghai", + "Asia/Harbin", + "Asia/Kashgar", + "America/Bogota", + "Europe/Madrid", + "Africa/Brazzaville", + "Africa/Bamako", + "Africa/Lubumbashi", + "Africa/Kinshasa", + "Pacific/Rarotonga", + "America/Costa_Rica", + "America/Mazatlan", + "Europe/Zagreb", + "America/Havana", + "America/Panama", + "America/Curacao", + "Asia/Nicosia", + "Europe/Prague", + "Europe/Copenhagen", + "Africa/Djibouti", + "Asia/Dili", + "America/Guayaquil", + "Pacific/Galapagos", + "Africa/Cairo", + "Europe/Tallinn", + "Africa/Addis_Ababa", + "Atlantic/Stanley", + "Atlantic/Faroe", + "Pacific/Pohnpei", + "Pacific/Fiji", + "Europe/Helsinki", + "Europe/Paris", + "America/Cayenne", + "America/Martinique", + "Indian/Reunion", + "Indian/Mayotte", + "Pacific/Tahiti", + "Africa/Libreville", + "Asia/Tbilisi", + "Africa/Accra", + "Europe/Gibraltar", + "Europe/Athens", + "Africa/Tripoli", + "America/Godthab", + "America/Danmarkshavn", + "America/Thule", + "Pacific/Guam", + "America/Guatemala", + "Africa/Conakry", + "Africa/Bissau", + "America/Port-au-Prince", + "America/Tegucigalpa", + "Asia/Hong_Kong", + "Europe/Budapest", + "Atlantic/Reykjavik", + "Asia/Kolkata", + "Asia/Jakarta", + "Asia/Jayapura", + "Asia/Makassar", + "Asia/Pontianak", + "Asia/Baghdad", + "Asia/Riyadh", + "Europe/Dublin", + "Asia/Jerusalem", + "Europe/Rome", + "Africa/Abidjan", + "Asia/Tokyo", + "Asia/Amman", + "Asia/Qyzylorda", + "Asia/Aqtau", + "Asia/Atyrau", + "Asia/Almaty", + "Asia/Aqtobe", + "Asia/Oral", + "Africa/Nairobi", + "Pacific/Tarawa", + "Europe/Belgrade", + "Asia/Kuwait", + "Asia/Bishkek", + "Asia/Vientiane", + "Europe/Riga", + "Africa/Monrovia", + "Asia/Aden", + "Europe/Vilnius", + "Europe/Luxembourg", + "Asia/Macau", + "Europe/Skopje", + "Indian/Antananarivo", + "Africa/Blantyre", + "Africa/Dar_es_Salaam", + "Asia/Kuching", + "Indian/Maldives", + "Europe/Malta", + "Pacific/Majuro", + "Africa/Nouakchott", + "Africa/Dakar", + "Indian/Mauritius", + "America/Tijuana", + "America/Monterrey", + "America/Chihuahua", + "America/Matamoros", + "America/Merida", + "America/Ojinaga", + "Europe/Moscow", + "America/Cancun", + "Europe/Chisinau", + "Asia/Ulaanbaatar", + "Asia/Hovd", + "Asia/Choibalsan", + "Europe/Podgorica", + "Africa/Casablanca", + "Africa/El_Aaiun", + "Africa/Lusaka", + "Asia/Rangoon", + "Africa/Windhoek", + "Asia/Kathmandu", + "Europe/Amsterdam", + "Pacific/Noumea", + "America/Managua", + "Africa/Niamey", + "Africa/Lagos", + "Asia/Pyongyang", + "Asia/Famagusta", + "Europe/Oslo", + "Asia/Muscat", + "Asia/Karachi", + "Pacific/Palau", + "Asia/Hebron", + "Asia/Gaza", + "Pacific/Port_Moresby", + "Pacific/Bougainville", + "Asia/Manila", + "Europe/Warsaw", + "Africa/Tunis", + "Atlantic/Azores", + "Atlantic/Madeira", + "Asia/Qatar", + "Europe/Bucharest", + "Asia/Irkutsk", + "Asia/Anadyr", + "Asia/Srednekolymsk", + "Europe/Kaliningrad", + "Europe/Volgograd", + "Asia/Yekaterinburg", + "Europe/Kirov", + "Europe/Samara", + "Asia/Novokuznetsk", + "Asia/Krasnoyarsk", + "Asia/Novosibirsk", + "Asia/Chita", + "Asia/Vladivostok", + "Asia/Yakutsk", + "Asia/Magadan", + "Asia/Sakhalin", + "Asia/Kamchatka", + "Asia/Tomsk", + "Europe/Saratov", + "Europe/Ulyanovsk", + "Asia/Omsk", + "Asia/Barnaul", + "Asia/Khandyga", + "Europe/Astrakhan", + "Asia/Ust-Nera", + "Africa/Kigali", + "Pacific/Apia", + "Africa/Sao_Tome", + "Africa/Freetown", + "Asia/Singapore", + "Europe/Bratislava", + "Europe/Ljubljana", + "Pacific/Guadalcanal", + "Africa/Mogadishu", + "Atlantic/South_Georgia", + "Asia/Seoul", + "Africa/Juba", + "Atlantic/Canary", + "Africa/Ceuta", + "Asia/Colombo", + "Africa/Khartoum", + "America/Paramaribo", + "Europe/Stockholm", + "Europe/Zurich", + "Asia/Damascus", + "Asia/Taipei", + "Asia/Dushanbe", + "Asia/Bangkok", + "Africa/Banjul", + "Africa/Lome", + "Pacific/Tongatapu", + "Asia/Ashgabat", + "America/Grand_Turk", + "Pacific/Funafuti", + "Africa/Kampala", + "Europe/Kiev", + "Europe/Uzhgorod", + "Europe/Zaporozhye", + "Europe/Simferopol", + "Asia/Dubai", + "America/Denver", + "America/Boise", + "America/Indiana/Indianapolis", + "America/Kentucky/Louisville", + "America/Menominee", + "America/Anchorage", + "America/Sitka", + "America/Nome", + "Pacific/Honolulu", + "America/Juneau", + "America/Adak", + "America/Yakutat", + "America/Montevideo", + "Asia/Samarkand", + "Asia/Tashkent", + "Pacific/Efate", + "Asia/Ho_Chi_Minh", + "Africa/Harare", +] as const;