Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff4f557f6 | ||
|
|
7e2c0bd29e | ||
|
|
5b59cf9d9c | ||
|
|
d5420eda00 | ||
|
|
c17ac67b3f | ||
|
|
b2763a6274 | ||
|
|
08b6f60407 | ||
|
|
466b3e4c08 | ||
|
|
401209810a | ||
|
|
9f34a840d0 | ||
|
|
37f1535e1a | ||
|
|
5dc81e8677 | ||
|
|
5a166ee430 | ||
|
|
f6f964d495 | ||
|
|
6364e85293 | ||
|
|
46add2bebc | ||
|
|
8ada940dc7 | ||
|
|
d027f8004a | ||
|
|
cc2f16411b | ||
|
|
15b58c1c13 | ||
|
|
2d12ea7372 | ||
|
|
06bf9cfbfe | ||
|
|
5dbc6d9ca6 | ||
|
|
486e3a386e | ||
|
|
3a29327761 | ||
|
|
4f226cd653 | ||
|
|
207804bd97 | ||
|
|
7969789d60 | ||
|
|
67fb0e6719 | ||
|
|
7f390b147a | ||
|
|
1dc869971e | ||
|
|
3fe83bb45c | ||
|
|
4fbeff31e7 | ||
|
|
e800341485 | ||
|
|
f5dc22f145 | ||
|
|
4fd31db6ee | ||
|
|
f58d3cf837 | ||
|
|
fc79ea898a | ||
|
|
5a602f10e6 | ||
|
|
e29b42a02b | ||
|
|
cdb57eaa59 | ||
|
|
662ba8dfc6 | ||
|
|
552d88ad6d | ||
|
|
08bcc77e2c | ||
|
|
c211f181fc | ||
|
|
cc6016aef3 | ||
|
|
77d0fa9ff8 | ||
|
|
720136fcf9 | ||
|
|
9bf3732717 |
+4
-1
@@ -12,8 +12,11 @@
|
||||
/packages/app-store/larkcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||
/packages/core/getAggregatedAvailability/index.ts @calcom/Foundation
|
||||
/packages/core/getAggregateWorkingHours.ts @calcom/Foundation
|
||||
/packages/core/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/handleNewBooking/**/* @calcom/Foundation
|
||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -30,7 +30,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.94",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.99",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -61,6 +61,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsforce": "^1.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.4",
|
||||
"nest-winston": "^1.9.4",
|
||||
"next-auth": "^4.22.1",
|
||||
|
||||
@@ -169,4 +169,19 @@ export class CreateBookingInput_2024_04_15 {
|
||||
label?: string | undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
teamMemberEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
crmAppSlug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
|
||||
@@ -455,7 +455,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
console.log("asap data", JSON.stringify(data, null, 2));
|
||||
expect(data.attendees[0].name).toEqual(`${splitName.firstName} ${splitName.lastName}`);
|
||||
expect(data.attendees[0].bookingFieldsResponses.name).toEqual(splitName);
|
||||
} else {
|
||||
|
||||
@@ -167,6 +167,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
rating: 10,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -396,6 +397,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
// Check updatedAt date is between the time of the request and after the request
|
||||
expect(data.updatedAt).toBeDefined();
|
||||
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
|
||||
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
expect(data.metadata).toEqual(body.metadata);
|
||||
createdBooking = data;
|
||||
} else {
|
||||
@@ -546,6 +553,30 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get a booking with rating", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${bookingInThePast.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toEqual(bookingInThePast.id);
|
||||
expect(data.uid).toEqual(bookingInThePast.uid);
|
||||
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get 1 recurrence of a recurring booking", async () => {
|
||||
const recurrenceUid = createdRecurringBooking[0].uid;
|
||||
return request(app.getHttpServer())
|
||||
@@ -869,7 +900,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
it("should should get bookings by before specified end time", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?afterStart=${createdRecurringBooking[0].start}`)
|
||||
.get(`/v2/bookings?beforeEnd=${createdRecurringBooking[0].end}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get bookings after specified update time", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?afterUpdatedAt=${createdRecurringBooking[1].updatedAt}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get bookings before specified update time", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?beforeUpdatedAt=${createdRecurringBooking[0].updatedAt}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("should should get bookings in update range", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/bookings?afterUpdatedAt=${createdRecurringBooking[0].updatedAt}&beforeUpdatedAt=${createdRecurringBooking[2].updatedAt}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -1004,6 +1091,46 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data[1].start).toEqual(createdBooking.start);
|
||||
});
|
||||
});
|
||||
|
||||
it("should should sort bookings by updated in descending order", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?eventTypeId=${eventTypeId}&sortUpdatedAt=desc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].updatedAt).toEqual(createdBooking.updatedAt);
|
||||
expect(data[1].updatedAt).toEqual(bookingInThePast.updatedAt?.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
it("should should sort bookings by updated in ascending order", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?eventTypeId=${eventTypeId}&sortUpdatedAt=asc`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data[0].updatedAt).toEqual(bookingInThePast.updatedAt?.toISOString());
|
||||
expect(data[1].updatedAt).toEqual(createdBooking.updatedAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reschedule bookings", () => {
|
||||
|
||||
@@ -463,11 +463,18 @@ export class InputBookingsService_2024_08_13 {
|
||||
teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined),
|
||||
eventTypeIds:
|
||||
queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined),
|
||||
afterUpdatedDate: queryParams.afterUpdatedAt,
|
||||
beforeUpdatedDate: queryParams.beforeUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) {
|
||||
if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) {
|
||||
if (
|
||||
!queryParams.sortStart &&
|
||||
!queryParams.sortEnd &&
|
||||
!queryParams.sortCreated &&
|
||||
!queryParams.sortUpdatedAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -475,6 +482,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
sortStart: queryParams.sortStart,
|
||||
sortEnd: queryParams.sortEnd,
|
||||
sortCreated: queryParams.sortCreated,
|
||||
sortUpdated: queryParams.sortUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
meetingUrl: location,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -225,6 +227,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
bookingFieldsResponses: databaseBooking.responses,
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
|
||||
@@ -271,6 +275,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
meetingUrl: location,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -280,7 +286,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData
|
||||
defaultSeatedBookingData,
|
||||
false
|
||||
);
|
||||
|
||||
const attendeeData = {
|
||||
@@ -297,7 +304,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata
|
||||
defaultSeatedBookingMetadata,
|
||||
false
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
@@ -376,6 +384,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
recurringBookingUid: databaseBooking.recurringEventId,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
|
||||
@@ -387,7 +397,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData
|
||||
defaultSeatedBookingData,
|
||||
false
|
||||
);
|
||||
|
||||
const attendeeData = {
|
||||
@@ -404,7 +415,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata
|
||||
defaultSeatedBookingMetadata,
|
||||
false
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
@@ -7,11 +7,7 @@ import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createUserEventType(
|
||||
userId: number,
|
||||
@@ -47,6 +43,13 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeWithHosts(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { hosts: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventType(userId: number, eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findFirst({
|
||||
where: {
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, IsString, ValidateNested, IsOptional } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -10,8 +10,9 @@ class Data {
|
||||
callId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiProperty()
|
||||
agentId!: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export class CreatePhoneCallOutput {
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
|
||||
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -41,7 +42,7 @@ const CALENDAR_SCOPES = [
|
||||
path: "/v2/gcal",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Platform / Google Calendar")
|
||||
@ApiExcludeController(true)
|
||||
export class GcalController {
|
||||
private readonly logger = new Logger("Platform Gcal Provider");
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { MeModule } from "@/ee/me/me.module";
|
||||
import { ProviderModule } from "@/ee/provider/provider.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SlotsModule } from "@/modules/slots/slots.module";
|
||||
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
@@ -29,7 +30,8 @@ import { Module } from "@nestjs/common";
|
||||
BookingsModule_2024_04_15,
|
||||
BookingsModule_2024_08_13,
|
||||
TeamsMembershipsModule,
|
||||
SlotsModule,
|
||||
SlotsModule_2024_04_15,
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_06_14,
|
||||
VERSION_2024_08_13,
|
||||
VERSION_2024_09_04,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
|
||||
@@ -13,8 +14,10 @@ export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unkn
|
||||
export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
|
||||
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
|
||||
export const VERSION_2024_08_13_VALUE: VersionValue = VERSION_2024_08_13 as unknown as VersionValue;
|
||||
export const VERSION_2024_09_04_VALUE: VersionValue = VERSION_2024_09_04 as unknown as VersionValue;
|
||||
|
||||
export { VERSION_2024_04_15 };
|
||||
export { VERSION_2024_06_11 };
|
||||
export { VERSION_2024_06_14 };
|
||||
export { VERSION_2024_08_13 };
|
||||
export { VERSION_2024_09_04 };
|
||||
|
||||
@@ -3,20 +3,27 @@ import { ZodSchema } from "zod";
|
||||
|
||||
const logger = new Logger("safeParse");
|
||||
|
||||
export function safeParse<T>(schema: ZodSchema<T>, value: unknown, defaultValue: T): T {
|
||||
export function safeParse<T>(
|
||||
schema: ZodSchema<T>,
|
||||
value: unknown,
|
||||
defaultValue: T,
|
||||
logError = true
|
||||
): T {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
const errorStack = new Error().stack;
|
||||
|
||||
logger.error(
|
||||
`Zod parsing failed.\n` +
|
||||
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
|
||||
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
|
||||
`3. Zod Error: ${result.error}\n` +
|
||||
`4. Call Stack: ${errorStack}`
|
||||
);
|
||||
if (logError) {
|
||||
logger.error(
|
||||
`Zod parsing failed.\n` +
|
||||
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
|
||||
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
|
||||
`3. Zod Error: ${result.error}\n` +
|
||||
`4. Call Stack: ${errorStack}`
|
||||
);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
|
||||
export const GetOptionalUser = createParamDecorator<
|
||||
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
|
||||
ExecutionContext
|
||||
>((data, ctx) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.reduce((prev, curr) => {
|
||||
return {
|
||||
...prev,
|
||||
[curr]: user[curr],
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return user[data];
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||
handleRequest(error: Error, user: any) {
|
||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE;
|
||||
if (user || noAuthProvided || !error) {
|
||||
return user || null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom
|
||||
|
||||
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
||||
|
||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||
@Injectable()
|
||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||
constructor(
|
||||
@@ -62,9 +64,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
return await this.authenticateNextAuth(nextAuthToken);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
|
||||
);
|
||||
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [MembershipsRepository],
|
||||
exports: [MembershipsRepository],
|
||||
providers: [MembershipsRepository, MembershipsService],
|
||||
exports: [MembershipsRepository, MembershipsService],
|
||||
})
|
||||
export class MembershipsModule {}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { intersectionBy } from "lodash";
|
||||
|
||||
@Injectable()
|
||||
export class MembershipsService {
|
||||
constructor(private readonly membershipsRepository: MembershipsRepository) {}
|
||||
|
||||
async haveMembershipsInCommon(firstUserId: number, secondUserId: number) {
|
||||
const memberships = await this.membershipsInCommon(firstUserId, secondUserId);
|
||||
return memberships.length > 0;
|
||||
}
|
||||
|
||||
async membershipsInCommon(firstUserId: number, secondUserId: number) {
|
||||
const firstUserMemberships = await this.membershipsRepository.findUserMemberships(firstUserId);
|
||||
const secondUserMemberships = await this.membershipsRepository.findUserMemberships(secondUserId);
|
||||
|
||||
return intersectionBy(
|
||||
firstUserMemberships.filter((m) => m.accepted),
|
||||
secondUserMemberships.filter((m) => m.accepted),
|
||||
"teamId"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,4 +131,14 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgBySlug(slug: string) {
|
||||
return this.dbRead.prisma.team.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
parentId: null,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+15
-16
@@ -37,22 +37,6 @@ export class OrganizationsUsersRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationUserByUsername(orgId: number, username: string) {
|
||||
return await this.dbRead.prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
...this.filterOnOrgMembership(orgId),
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationUserByEmail(orgId: number, email: string) {
|
||||
return await this.dbRead.prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -109,4 +93,19 @@ export class OrganizationsUsersRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationUserByUsername(orgId: number, username: string) {
|
||||
const profile = await this.dbRead.prisma.profile.findUnique({
|
||||
where: {
|
||||
username_organizationId: {
|
||||
organizationId: orgId,
|
||||
username,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
return profile?.user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/platform-libraries";
|
||||
import {
|
||||
getRoutedUrl,
|
||||
getTeamMemberEmailForResponseOrContactUsingUrlQuery,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -13,6 +17,8 @@ import { ApiResponse } from "@calcom/platform-types";
|
||||
@DocsTags("Router controller")
|
||||
@DocsExcludeController(true)
|
||||
export class RouterController {
|
||||
constructor(private readonly teamsEventTypesRepository: TeamsEventTypesRepository) {}
|
||||
|
||||
@Post("/forms/:formId/submit")
|
||||
async getRoutingFormResponse(
|
||||
@Req() request: Request,
|
||||
@@ -27,13 +33,58 @@ export class RouterController {
|
||||
}
|
||||
|
||||
if (routedUrlData?.redirect?.destination) {
|
||||
return { status: "success", data: routedUrlData?.redirect?.destination, redirect: true };
|
||||
return this.handleRedirect(routedUrlData.redirect.destination);
|
||||
}
|
||||
|
||||
if (routedUrlData?.props) {
|
||||
return { status: "success", data: { message: routedUrlData?.props?.message ?? "" }, redirect: false };
|
||||
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
|
||||
}
|
||||
|
||||
return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false };
|
||||
}
|
||||
|
||||
private async handleRedirect(destination: string): Promise<ApiResponse<unknown> & { redirect: boolean }> {
|
||||
const routingUrl = new URL(destination);
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
if (
|
||||
routingSearchParams.get("cal.action") === "eventTypeRedirectUrl" &&
|
||||
routingSearchParams.has("email") &&
|
||||
routingSearchParams.has("cal.teamId") &&
|
||||
!routingSearchParams.has("cal.skipContactOwner")
|
||||
) {
|
||||
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
|
||||
}
|
||||
|
||||
return { status: "success", data: destination, redirect: true };
|
||||
}
|
||||
|
||||
private async handleRedirectWithContactOwner(
|
||||
routingUrl: URL,
|
||||
routingSearchParams: URLSearchParams
|
||||
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
|
||||
const pathNameParams = routingUrl.pathname.split("/");
|
||||
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
|
||||
const teamId = Number(routingSearchParams.get("cal.teamId"));
|
||||
const eventTypeData = await this.teamsEventTypesRepository.getTeamEventTypeBySlug(
|
||||
teamId,
|
||||
eventTypeSlug,
|
||||
3
|
||||
);
|
||||
|
||||
// get the salesforce record owner email for the email given as a form response.
|
||||
const {
|
||||
email: teamMemberEmail,
|
||||
recordType: crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: Object.fromEntries(routingSearchParams),
|
||||
eventData: eventTypeData,
|
||||
});
|
||||
|
||||
Boolean(teamMemberEmail) && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail);
|
||||
Boolean(crmOwnerRecordType) && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType);
|
||||
Boolean(crmAppSlug) && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug);
|
||||
|
||||
return { status: "success", data: routingUrl.toString(), redirect: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RouterController } from "@/modules/router/controllers/router.controller";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [],
|
||||
providers: [TeamsEventTypesRepository],
|
||||
exports: [],
|
||||
controllers: [RouterController],
|
||||
})
|
||||
|
||||
+202
-5
@@ -4,7 +4,7 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SlotsModule } from "@/modules/slots/slots.module";
|
||||
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
@@ -12,6 +12,8 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { AttendeeRepositoryFixture } from "test/fixtures/repository/attendee.repository.fixture";
|
||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
|
||||
@@ -241,7 +243,7 @@ const expectedSlotsRomeRange = {
|
||||
},
|
||||
};
|
||||
|
||||
describe("Slots Endpoints", () => {
|
||||
describe("Slots 2024-04-15 Endpoints", () => {
|
||||
describe("Individual user slots", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -250,6 +252,8 @@ describe("Slots Endpoints", () => {
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let bookingSeatsRepositoryFixture: BookingSeatRepositoryFixture;
|
||||
let attendeesRepositoryFixture: AttendeeRepositoryFixture;
|
||||
|
||||
const userEmail = `slots-${randomString()}-controller-e2e@api.com`;
|
||||
const userName = "bob";
|
||||
@@ -258,6 +262,9 @@ describe("Slots Endpoints", () => {
|
||||
let eventTypeSlug: string;
|
||||
let reservedSlotUid: string;
|
||||
|
||||
const seatedEventTypeSlug = "peer-coding-seated";
|
||||
let seatedEventTypeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -268,7 +275,7 @@ describe("Slots Endpoints", () => {
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule_2024_06_11,
|
||||
SlotsModule,
|
||||
SlotsModule_2024_04_15,
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -283,6 +290,8 @@ describe("Slots Endpoints", () => {
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
bookingSeatsRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
|
||||
attendeesRepositoryFixture = new AttendeeRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -291,7 +300,7 @@ describe("Slots Endpoints", () => {
|
||||
});
|
||||
|
||||
// nxte(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
|
||||
const userSchedule = await schedulesService.createUserSchedule(user.id, {
|
||||
await schedulesService.createUserSchedule(user.id, {
|
||||
name: `slots-schedule-${randomString()}-slots.controller.e2e-spec`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
@@ -306,9 +315,24 @@ describe("Slots Endpoints", () => {
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
eventTypeId = eventType.id;
|
||||
eventTypeSlug = eventType.slug;
|
||||
|
||||
const seatedEvent = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `slots-event-type-seated-${randomString()}-slots.controller.e2e-spec`,
|
||||
slug: `slots-event-type-seated-${randomString()}-slots.controller.e2e-spec`,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 5,
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
},
|
||||
user.id
|
||||
);
|
||||
seatedEventTypeId = seatedEvent.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -445,7 +469,7 @@ describe("Slots Endpoints", () => {
|
||||
|
||||
it("should do a booking and slot should not be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
await bookingsRepositoryFixture.create({
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${eventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
@@ -486,6 +510,179 @@ describe("Slots Endpoints", () => {
|
||||
expect(slots).toEqual({
|
||||
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
|
||||
});
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${seatedEventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: seatedEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const attendee = await attendeesRepositoryFixture.create({
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
timeZone: "Europe/London",
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bookingSeatsRepositoryFixture.create({
|
||||
referenceUid: "100",
|
||||
data: {},
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
attendee: {
|
||||
connect: {
|
||||
id: attendee.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/slots/available?eventTypeId=${seatedEventTypeId}&startTime=2050-09-05&endTime=2050-09-10`
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots.slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = [
|
||||
{ time: "2050-09-05T07:00:00.000Z" },
|
||||
{ time: "2050-09-05T08:00:00.000Z" },
|
||||
{ time: "2050-09-05T09:00:00.000Z" },
|
||||
{ time: "2050-09-05T10:00:00.000Z" },
|
||||
{ time: "2050-09-05T11:00:00.000Z", attendees: 1, bookingUid: booking.uid },
|
||||
{ time: "2050-09-05T12:00:00.000Z" },
|
||||
{ time: "2050-09-05T13:00:00.000Z" },
|
||||
{ time: "2050-09-05T14:00:00.000Z" },
|
||||
];
|
||||
|
||||
expect(slots).toEqual({
|
||||
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
|
||||
});
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${seatedEventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: seatedEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const attendee = await attendeesRepositoryFixture.create({
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
timeZone: "Europe/London",
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
bookingSeatsRepositoryFixture.create({
|
||||
referenceUid: "100",
|
||||
data: {},
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
attendee: {
|
||||
connect: {
|
||||
id: attendee.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/slots/available?eventTypeId=${seatedEventTypeId}&startTime=2050-09-05&endTime=2050-09-10&slotFormat=range`
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots.slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = [
|
||||
{ startTime: "2050-09-05T07:00:00.000Z", endTime: "2050-09-05T08:00:00.000Z" },
|
||||
{ startTime: "2050-09-05T08:00:00.000Z", endTime: "2050-09-05T09:00:00.000Z" },
|
||||
{ startTime: "2050-09-05T09:00:00.000Z", endTime: "2050-09-05T10:00:00.000Z" },
|
||||
{ startTime: "2050-09-05T10:00:00.000Z", endTime: "2050-09-05T11:00:00.000Z" },
|
||||
{
|
||||
startTime: "2050-09-05T11:00:00.000Z",
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
attendees: 1,
|
||||
bookingUid: booking.uid,
|
||||
},
|
||||
{ startTime: "2050-09-05T12:00:00.000Z", endTime: "2050-09-05T13:00:00.000Z" },
|
||||
{ startTime: "2050-09-05T13:00:00.000Z", endTime: "2050-09-05T14:00:00.000Z" },
|
||||
{ startTime: "2050-09-05T14:00:00.000Z", endTime: "2050-09-05T15:00:00.000Z" },
|
||||
];
|
||||
|
||||
expect(slots).toEqual({
|
||||
slots: { ...expectedSlotsUTCRange.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
|
||||
});
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
+20
-14
@@ -1,25 +1,31 @@
|
||||
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 { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
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 {
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_06_14,
|
||||
VERSION_2024_04_15,
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries";
|
||||
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
|
||||
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/slots",
|
||||
version: API_VERSIONS_VALUES,
|
||||
version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14, VERSION_2024_08_13],
|
||||
})
|
||||
@DocsTags("Slots")
|
||||
export class SlotsController {
|
||||
@DocsExcludeController(true)
|
||||
export class SlotsController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly slotsService: SlotsService,
|
||||
private readonly slotsOutputService: SlotsOutputService
|
||||
private readonly slotsService: SlotsService_2024_04_15,
|
||||
private readonly slotsOutputService: SlotsOutputService_2024_04_15
|
||||
) {}
|
||||
|
||||
@Post("/reserve")
|
||||
@@ -40,7 +46,7 @@ export class SlotsController {
|
||||
})
|
||||
@ApiOperation({ summary: "Reserve a slot" })
|
||||
async reserveSlot(
|
||||
@Body() body: ReserveSlotInput,
|
||||
@Body() body: ReserveSlotInput_2024_04_15,
|
||||
@Res({ passthrough: true }) res: ExpressResponse,
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse<string>> {
|
||||
@@ -65,7 +71,7 @@ export class SlotsController {
|
||||
})
|
||||
@ApiOperation({ summary: "Delete a selected slot" })
|
||||
async deleteSelectedSlot(
|
||||
@Query() params: RemoveSelectedSlotInput,
|
||||
@Query() params: RemoveSelectedSlotInput_2024_04_15,
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse> {
|
||||
const uid = req.cookies?.uid || params.uid;
|
||||
@@ -148,7 +154,7 @@ export class SlotsController {
|
||||
})
|
||||
@ApiOperation({ summary: "Get available slots" })
|
||||
async getAvailableSlots(
|
||||
@Query() query: GetAvailableSlotsInput,
|
||||
@Query() query: GetAvailableSlotsInput_2024_04_15,
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse<AvailableSlotsType>> {
|
||||
const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId);
|
||||
+15
-5
@@ -4,11 +4,15 @@ 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 }[]> };
|
||||
export type TimeSlots = {
|
||||
slots: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>;
|
||||
};
|
||||
type RangeSlots = {
|
||||
slots: Record<string, { startTime: string; endTime: string; attendees?: number; bookingUid?: string }[]>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SlotsOutputService {
|
||||
export class SlotsOutputService_2024_04_15 {
|
||||
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {}
|
||||
|
||||
async getOutputSlots(
|
||||
@@ -30,6 +34,8 @@ export class SlotsOutputService {
|
||||
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",
|
||||
...(slot.attendees ? { attendees: slot.attendees } : {}),
|
||||
...(slot.bookingUid ? { bookingUid: slot.bookingUid } : {}),
|
||||
}));
|
||||
return acc;
|
||||
}, {} as Record<string, { time: string }[]>);
|
||||
@@ -42,6 +48,8 @@ export class SlotsOutputService {
|
||||
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",
|
||||
...(slot.attendees ? { attendees: slot.attendees } : {}),
|
||||
...(slot.bookingUid ? { bookingUid: slot.bookingUid } : {}),
|
||||
}));
|
||||
return acc;
|
||||
}, {} as Record<string, { startTime: string; endTime: string }[]>);
|
||||
@@ -62,14 +70,16 @@ export class SlotsOutputService {
|
||||
const slotDuration = await this.getDuration(duration, eventTypeId);
|
||||
|
||||
const slots = Object.entries(availableSlots.slots).reduce<
|
||||
Record<string, { startTime: string; endTime: string }[]>
|
||||
Record<string, { startTime: string; endTime: string; attendees?: number; bookingUid?: string }[]>
|
||||
>((acc, [date, slots]) => {
|
||||
acc[date] = (slots as { time: string }[]).map((slot) => {
|
||||
acc[date] = (slots as { time: string; attendees?: number; bookingUid?: string }[]).map((slot) => {
|
||||
const startTime = new Date(slot.time);
|
||||
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
|
||||
return {
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
...(slot.attendees ? { attendees: slot.attendees } : {}),
|
||||
...(slot.bookingUid ? { bookingUid: slot.bookingUid } : {}),
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
+5
-5
@@ -1,20 +1,20 @@
|
||||
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 { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/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";
|
||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||
import { ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SlotsService {
|
||||
export class SlotsService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypeRepo: EventTypesRepository_2024_04_15,
|
||||
private readonly slotsRepo: SlotsRepository
|
||||
private readonly slotsRepo: SlotsRepository_2024_04_15
|
||||
) {}
|
||||
|
||||
async reserveSlot(input: ReserveSlotInput, headerUid?: string) {
|
||||
async reserveSlot(input: ReserveSlotInput_2024_04_15, headerUid?: string) {
|
||||
const uid = headerUid || uuid();
|
||||
const eventType = await this.eventTypeRepo.getEventTypeWithSeats(input.eventTypeId);
|
||||
if (!eventType) {
|
||||
@@ -0,0 +1,15 @@
|
||||
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_2024_04_15 } from "@/modules/slots/slots-2024-04-15/controllers/slots.controller";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_04_15],
|
||||
providers: [SlotsRepository_2024_04_15, SlotsService_2024_04_15, SlotsOutputService_2024_04_15],
|
||||
controllers: [SlotsController_2024_04_15],
|
||||
exports: [SlotsService_2024_04_15],
|
||||
})
|
||||
export class SlotsModule_2024_04_15 {}
|
||||
+3
-3
@@ -4,10 +4,10 @@ import { Injectable } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
|
||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||
import { ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SlotsRepository {
|
||||
export class SlotsRepository_2024_04_15 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getBookingWithAttendees(bookingUid?: string) {
|
||||
@@ -17,7 +17,7 @@ export class SlotsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertSelectedSlot(userId: number, input: ReserveSlotInput, uid: string, isSeat: boolean) {
|
||||
async upsertSelectedSlot(userId: number, input: ReserveSlotInput_2024_04_15, uid: string, isSeat: boolean) {
|
||||
const { slotUtcEndDate, slotUtcStartDate, eventTypeId } = input;
|
||||
|
||||
const releaseAt = DateTime.utc()
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import {
|
||||
expectedSlotsRome,
|
||||
expectedSlotsUTC,
|
||||
} from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
|
||||
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.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 { Profile, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
|
||||
import { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Slots 2024-09-04 Endpoints", () => {
|
||||
describe("Dynamic event type slots", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_06_11;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const userEmailOne = `slots-2024-09-04-user-1-dynamic-slots-${randomString()}@api.com`;
|
||||
const userEmailTwo = `slots-2024-09-04-user-2-dynamic-slots-${randomString()}@api.com`;
|
||||
|
||||
let organization: Team;
|
||||
let userOne: User;
|
||||
let userTwo: User;
|
||||
let orgProfileOne: Profile;
|
||||
let orgProfileTwo: Profile;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmailOne,
|
||||
Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule_2024_06_11,
|
||||
SlotsModule_2024_09_04,
|
||||
],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
const orgSlug = `slots-2024-09-04-org-${randomString()}`;
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: orgSlug,
|
||||
isOrganization: true,
|
||||
slug: orgSlug,
|
||||
});
|
||||
|
||||
userOne = await userRepositoryFixture.create({
|
||||
email: userEmailOne,
|
||||
name: userEmailOne,
|
||||
username: userEmailOne,
|
||||
});
|
||||
|
||||
userTwo = await userRepositoryFixture.create({
|
||||
email: userEmailTwo,
|
||||
name: userEmailTwo,
|
||||
username: userEmailTwo,
|
||||
});
|
||||
|
||||
orgProfileOne = await profileRepositoryFixture.create({
|
||||
uid: `usr-${userOne.id}`,
|
||||
username: "teammate-one",
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: userOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
orgProfileTwo = await profileRepositoryFixture.create({
|
||||
uid: `usr-${userTwo.id}`,
|
||||
username: "teammate-two",
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: userTwo.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
|
||||
await schedulesService.createUserSchedule(userOne.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(userTwo.id, userSchedule);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should get slots in UTC by usernames", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?usernames=${orgProfileOne.username},${orgProfileTwo.username}&organizationSlug=${organization.slug}&start=2050-09-05&end=2050-09-09&duration=60`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get slots in specified timezone and in specified duration by usernames", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?usernames=${orgProfileOne.username},${orgProfileTwo.username}&organizationSlug=${organization.slug}&start=2050-09-05&end=2050-09-09&duration=60&timeZone=Europe/Rome`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsRome);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userOne.email);
|
||||
await userRepositoryFixture.deleteByEmail(userTwo.email);
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
export const expectedSlotsUTC = {
|
||||
"2050-09-05": [
|
||||
{ start: "2050-09-05T07:00:00.000Z" },
|
||||
{ start: "2050-09-05T08:00:00.000Z" },
|
||||
{ start: "2050-09-05T09:00:00.000Z" },
|
||||
{ start: "2050-09-05T10:00:00.000Z" },
|
||||
{ start: "2050-09-05T11:00:00.000Z" },
|
||||
{ start: "2050-09-05T12:00:00.000Z" },
|
||||
{ start: "2050-09-05T13:00:00.000Z" },
|
||||
{ start: "2050-09-05T14:00:00.000Z" },
|
||||
],
|
||||
"2050-09-06": [
|
||||
{ start: "2050-09-06T07:00:00.000Z" },
|
||||
{ start: "2050-09-06T08:00:00.000Z" },
|
||||
{ start: "2050-09-06T09:00:00.000Z" },
|
||||
{ start: "2050-09-06T10:00:00.000Z" },
|
||||
{ start: "2050-09-06T11:00:00.000Z" },
|
||||
{ start: "2050-09-06T12:00:00.000Z" },
|
||||
{ start: "2050-09-06T13:00:00.000Z" },
|
||||
{ start: "2050-09-06T14:00:00.000Z" },
|
||||
],
|
||||
"2050-09-07": [
|
||||
{ start: "2050-09-07T07:00:00.000Z" },
|
||||
{ start: "2050-09-07T08:00:00.000Z" },
|
||||
{ start: "2050-09-07T09:00:00.000Z" },
|
||||
{ start: "2050-09-07T10:00:00.000Z" },
|
||||
{ start: "2050-09-07T11:00:00.000Z" },
|
||||
{ start: "2050-09-07T12:00:00.000Z" },
|
||||
{ start: "2050-09-07T13:00:00.000Z" },
|
||||
{ start: "2050-09-07T14:00:00.000Z" },
|
||||
],
|
||||
"2050-09-08": [
|
||||
{ start: "2050-09-08T07:00:00.000Z" },
|
||||
{ start: "2050-09-08T08:00:00.000Z" },
|
||||
{ start: "2050-09-08T09:00:00.000Z" },
|
||||
{ start: "2050-09-08T10:00:00.000Z" },
|
||||
{ start: "2050-09-08T11:00:00.000Z" },
|
||||
{ start: "2050-09-08T12:00:00.000Z" },
|
||||
{ start: "2050-09-08T13:00:00.000Z" },
|
||||
{ start: "2050-09-08T14:00:00.000Z" },
|
||||
],
|
||||
"2050-09-09": [
|
||||
{ start: "2050-09-09T07:00:00.000Z" },
|
||||
{ start: "2050-09-09T08:00:00.000Z" },
|
||||
{ start: "2050-09-09T09:00:00.000Z" },
|
||||
{ start: "2050-09-09T10:00:00.000Z" },
|
||||
{ start: "2050-09-09T11:00:00.000Z" },
|
||||
{ start: "2050-09-09T12:00:00.000Z" },
|
||||
{ start: "2050-09-09T13:00:00.000Z" },
|
||||
{ start: "2050-09-09T14:00:00.000Z" },
|
||||
],
|
||||
};
|
||||
|
||||
export const expectedSlotsRome = {
|
||||
"2050-09-05": [
|
||||
{ start: "2050-09-05T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T16:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-06": [
|
||||
{ start: "2050-09-06T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T16:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-07": [
|
||||
{ start: "2050-09-07T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T16:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-08": [
|
||||
{ start: "2050-09-08T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T16:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-09": [
|
||||
{ start: "2050-09-09T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T16:00:00.000+02:00" },
|
||||
],
|
||||
};
|
||||
|
||||
export const expectedSlotsUTCRange = {
|
||||
"2050-09-05": [
|
||||
{ start: "2050-09-05T07:00:00.000Z", end: "2050-09-05T08:00:00.000Z" },
|
||||
{ start: "2050-09-05T08:00:00.000Z", end: "2050-09-05T09:00:00.000Z" },
|
||||
{ start: "2050-09-05T09:00:00.000Z", end: "2050-09-05T10:00:00.000Z" },
|
||||
{ start: "2050-09-05T10:00:00.000Z", end: "2050-09-05T11:00:00.000Z" },
|
||||
{ start: "2050-09-05T11:00:00.000Z", end: "2050-09-05T12:00:00.000Z" },
|
||||
{ start: "2050-09-05T12:00:00.000Z", end: "2050-09-05T13:00:00.000Z" },
|
||||
{ start: "2050-09-05T13:00:00.000Z", end: "2050-09-05T14:00:00.000Z" },
|
||||
{ start: "2050-09-05T14:00:00.000Z", end: "2050-09-05T15:00:00.000Z" },
|
||||
],
|
||||
"2050-09-06": [
|
||||
{ start: "2050-09-06T07:00:00.000Z", end: "2050-09-06T08:00:00.000Z" },
|
||||
{ start: "2050-09-06T08:00:00.000Z", end: "2050-09-06T09:00:00.000Z" },
|
||||
{ start: "2050-09-06T09:00:00.000Z", end: "2050-09-06T10:00:00.000Z" },
|
||||
{ start: "2050-09-06T10:00:00.000Z", end: "2050-09-06T11:00:00.000Z" },
|
||||
{ start: "2050-09-06T11:00:00.000Z", end: "2050-09-06T12:00:00.000Z" },
|
||||
{ start: "2050-09-06T12:00:00.000Z", end: "2050-09-06T13:00:00.000Z" },
|
||||
{ start: "2050-09-06T13:00:00.000Z", end: "2050-09-06T14:00:00.000Z" },
|
||||
{ start: "2050-09-06T14:00:00.000Z", end: "2050-09-06T15:00:00.000Z" },
|
||||
],
|
||||
"2050-09-07": [
|
||||
{ start: "2050-09-07T07:00:00.000Z", end: "2050-09-07T08:00:00.000Z" },
|
||||
{ start: "2050-09-07T08:00:00.000Z", end: "2050-09-07T09:00:00.000Z" },
|
||||
{ start: "2050-09-07T09:00:00.000Z", end: "2050-09-07T10:00:00.000Z" },
|
||||
{ start: "2050-09-07T10:00:00.000Z", end: "2050-09-07T11:00:00.000Z" },
|
||||
{ start: "2050-09-07T11:00:00.000Z", end: "2050-09-07T12:00:00.000Z" },
|
||||
{ start: "2050-09-07T12:00:00.000Z", end: "2050-09-07T13:00:00.000Z" },
|
||||
{ start: "2050-09-07T13:00:00.000Z", end: "2050-09-07T14:00:00.000Z" },
|
||||
{ start: "2050-09-07T14:00:00.000Z", end: "2050-09-07T15:00:00.000Z" },
|
||||
],
|
||||
"2050-09-08": [
|
||||
{ start: "2050-09-08T07:00:00.000Z", end: "2050-09-08T08:00:00.000Z" },
|
||||
{ start: "2050-09-08T08:00:00.000Z", end: "2050-09-08T09:00:00.000Z" },
|
||||
{ start: "2050-09-08T09:00:00.000Z", end: "2050-09-08T10:00:00.000Z" },
|
||||
{ start: "2050-09-08T10:00:00.000Z", end: "2050-09-08T11:00:00.000Z" },
|
||||
{ start: "2050-09-08T11:00:00.000Z", end: "2050-09-08T12:00:00.000Z" },
|
||||
{ start: "2050-09-08T12:00:00.000Z", end: "2050-09-08T13:00:00.000Z" },
|
||||
{ start: "2050-09-08T13:00:00.000Z", end: "2050-09-08T14:00:00.000Z" },
|
||||
{ start: "2050-09-08T14:00:00.000Z", end: "2050-09-08T15:00:00.000Z" },
|
||||
],
|
||||
"2050-09-09": [
|
||||
{ start: "2050-09-09T07:00:00.000Z", end: "2050-09-09T08:00:00.000Z" },
|
||||
{ start: "2050-09-09T08:00:00.000Z", end: "2050-09-09T09:00:00.000Z" },
|
||||
{ start: "2050-09-09T09:00:00.000Z", end: "2050-09-09T10:00:00.000Z" },
|
||||
{ start: "2050-09-09T10:00:00.000Z", end: "2050-09-09T11:00:00.000Z" },
|
||||
{ start: "2050-09-09T11:00:00.000Z", end: "2050-09-09T12:00:00.000Z" },
|
||||
{ start: "2050-09-09T12:00:00.000Z", end: "2050-09-09T13:00:00.000Z" },
|
||||
{ start: "2050-09-09T13:00:00.000Z", end: "2050-09-09T14:00:00.000Z" },
|
||||
{ start: "2050-09-09T14:00:00.000Z", end: "2050-09-09T15:00:00.000Z" },
|
||||
],
|
||||
};
|
||||
|
||||
export const expectedSlotsRomeRange = {
|
||||
"2050-09-05": [
|
||||
{ start: "2050-09-05T09:00:00.000+02:00", end: "2050-09-05T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T10:00:00.000+02:00", end: "2050-09-05T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T11:00:00.000+02:00", end: "2050-09-05T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T12:00:00.000+02:00", end: "2050-09-05T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T13:00:00.000+02:00", end: "2050-09-05T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T14:00:00.000+02:00", end: "2050-09-05T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T15:00:00.000+02:00", end: "2050-09-05T16:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T16:00:00.000+02:00", end: "2050-09-05T17:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-06": [
|
||||
{ start: "2050-09-06T09:00:00.000+02:00", end: "2050-09-06T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T10:00:00.000+02:00", end: "2050-09-06T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T11:00:00.000+02:00", end: "2050-09-06T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T12:00:00.000+02:00", end: "2050-09-06T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T13:00:00.000+02:00", end: "2050-09-06T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T14:00:00.000+02:00", end: "2050-09-06T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T15:00:00.000+02:00", end: "2050-09-06T16:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T16:00:00.000+02:00", end: "2050-09-06T17:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-07": [
|
||||
{ start: "2050-09-07T09:00:00.000+02:00", end: "2050-09-07T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T10:00:00.000+02:00", end: "2050-09-07T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T11:00:00.000+02:00", end: "2050-09-07T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T12:00:00.000+02:00", end: "2050-09-07T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T13:00:00.000+02:00", end: "2050-09-07T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T14:00:00.000+02:00", end: "2050-09-07T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T15:00:00.000+02:00", end: "2050-09-07T16:00:00.000+02:00" },
|
||||
{ start: "2050-09-07T16:00:00.000+02:00", end: "2050-09-07T17:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-08": [
|
||||
{ start: "2050-09-08T09:00:00.000+02:00", end: "2050-09-08T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T10:00:00.000+02:00", end: "2050-09-08T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T11:00:00.000+02:00", end: "2050-09-08T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T12:00:00.000+02:00", end: "2050-09-08T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T13:00:00.000+02:00", end: "2050-09-08T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T14:00:00.000+02:00", end: "2050-09-08T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T15:00:00.000+02:00", end: "2050-09-08T16:00:00.000+02:00" },
|
||||
{ start: "2050-09-08T16:00:00.000+02:00", end: "2050-09-08T17:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-09": [
|
||||
{ start: "2050-09-09T09:00:00.000+02:00", end: "2050-09-09T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T10:00:00.000+02:00", end: "2050-09-09T11:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T11:00:00.000+02:00", end: "2050-09-09T12:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T12:00:00.000+02:00", end: "2050-09-09T13:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T13:00:00.000+02:00", end: "2050-09-09T14:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T14:00:00.000+02:00", end: "2050-09-09T15:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T15:00:00.000+02:00", end: "2050-09-09T16:00:00.000+02:00" },
|
||||
{ start: "2050-09-09T16:00:00.000+02:00", end: "2050-09-09T17:00:00.000+02:00" },
|
||||
],
|
||||
};
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { expectedSlotsUTC } from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
|
||||
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.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 { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
|
||||
import { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Slots 2024-09-04 Endpoints", () => {
|
||||
describe("Organization team event type slots", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_06_11;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
|
||||
const sharedUsername = `slots-2024-09-04-shared-username-${randomString()}`;
|
||||
const sharedEventTypeSlug = `slots-2024-09-04-shared-event-type-slug-${randomString()}`;
|
||||
|
||||
const orgUserEmailOne = `slots-2024-09-04-org-user-one-${randomString()}@api.com`;
|
||||
const orgUserEmailTwo = `slots-2024-09-04-org-user-two-${randomString()}@api.com`;
|
||||
|
||||
const nonOrgUserEmailOne = `slots-2024-09-04-non-org-user-one-${randomString()}@api.com`;
|
||||
|
||||
let organization: Team;
|
||||
let team: Team;
|
||||
let orgUserOne: User;
|
||||
let orgUserTwo: User;
|
||||
let collectiveEventTypeId: number;
|
||||
let roundRobinEventTypeId: number;
|
||||
let collectiveBookingId: number;
|
||||
let roundRobinBookingId: number;
|
||||
let fullyBookedRoundRobinBookingIdOne: number;
|
||||
let fullyBookedRoundRobinBookingIdTwo: number;
|
||||
|
||||
let nonOrgUser: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
orgUserEmailOne,
|
||||
Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule_2024_06_11,
|
||||
SlotsModule_2024_09_04,
|
||||
],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
|
||||
const orgSlug = `slots-2024-09-04-organization-${randomString()}`;
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: orgSlug,
|
||||
isOrganization: true,
|
||||
slug: orgSlug,
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
orgUserOne = await userRepositoryFixture.create({
|
||||
email: orgUserEmailOne,
|
||||
name: orgUserEmailOne,
|
||||
username: orgUserEmailOne,
|
||||
});
|
||||
|
||||
await eventTypesRepositoryFixture.create(
|
||||
{ title: "frisbee match orgUserOne", slug: sharedEventTypeSlug, length: 60 },
|
||||
orgUserOne.id
|
||||
);
|
||||
|
||||
orgUserTwo = await userRepositoryFixture.create({
|
||||
email: orgUserEmailTwo,
|
||||
name: orgUserEmailTwo,
|
||||
username: orgUserEmailTwo,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmailOne,
|
||||
name: nonOrgUserEmailOne,
|
||||
username: sharedUsername,
|
||||
});
|
||||
|
||||
await eventTypesRepositoryFixture.create(
|
||||
{ title: "frisbee match nonOrgUser", slug: sharedEventTypeSlug, length: 60 },
|
||||
nonOrgUser.id
|
||||
);
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${orgUserOne.id}`,
|
||||
username: sharedUsername,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUserOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `slots-2024-09-04-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: orgUserOne.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: orgUserTwo.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: `slots-2024-09-04-org-collective-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: orgUserOne.id }, { id: orgUserTwo.id }],
|
||||
},
|
||||
});
|
||||
collectiveEventTypeId = collectiveEventType.id;
|
||||
|
||||
const roundRobinEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "RR Event Type",
|
||||
slug: `slots-2024-09-04-org-round-robin-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: orgUserOne.id }, { id: orgUserTwo.id }],
|
||||
},
|
||||
});
|
||||
roundRobinEventTypeId = roundRobinEventType.id;
|
||||
|
||||
const orgUsersSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const nonOrgUserSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
availability: [
|
||||
{
|
||||
days: ["Monday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
|
||||
await schedulesService.createUserSchedule(orgUserOne.id, orgUsersSchedule);
|
||||
await schedulesService.createUserSchedule(orgUserTwo.id, orgUsersSchedule);
|
||||
await schedulesService.createUserSchedule(nonOrgUser.id, nonOrgUserSchedule);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("org and non org user have the same username and event type slug", () => {
|
||||
it("should get org user event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?organizationSlug=${organization.slug}&eventTypeSlug=${sharedEventTypeSlug}&username=${sharedUsername}&start=2050-09-05&end=2050-09-09`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get non org user event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?&eventTypeSlug=${sharedEventTypeSlug}&username=${sharedUsername}&start=2050-09-05&end=2050-09-09`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(1);
|
||||
expect(slots).toEqual({ "2050-09-05": expectedSlotsUTC["2050-09-05"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should get collective team event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should book collective event type and slot should not be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${collectiveEventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: collectiveEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUserOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
collectiveBookingId = booking.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
(slot) => slot.start !== startTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should book round robin event type and slot should be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${roundRobinEventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: roundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUserOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
roundRobinBookingId = booking.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fully book round robin event type and slot should not be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const bookingOne = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${roundRobinEventTypeId}-1`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: roundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUserOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
fullyBookedRoundRobinBookingIdOne = bookingOne.id;
|
||||
|
||||
const bookingTwo = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${roundRobinEventTypeId}-2`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: roundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUserTwo.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
fullyBookedRoundRobinBookingIdTwo = bookingTwo.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
(slot) => slot.start !== startTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
bookingsRepositoryFixture.deleteById(bookingOne.id);
|
||||
bookingsRepositoryFixture.deleteById(bookingTwo.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(orgUserOne.email);
|
||||
await userRepositoryFixture.deleteByEmail(orgUserTwo.email);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await bookingsRepositoryFixture.deleteById(collectiveBookingId);
|
||||
await bookingsRepositoryFixture.deleteById(roundRobinBookingId);
|
||||
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdOne);
|
||||
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdTwo);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { expectedSlotsUTC } from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
|
||||
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
||||
import { ReserveSlotOutputResponse_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/reserve-slot.output";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.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 { advanceTo, clear } from "jest-date-mock";
|
||||
import { DateTime } from "luxon";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Slots 2024-09-04 Endpoints", () => {
|
||||
describe("Team event type slots", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_06_11;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
|
||||
|
||||
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
|
||||
let teammateApiKeyString: string;
|
||||
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
|
||||
|
||||
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
|
||||
let outsider: User;
|
||||
let outsiderApiKeyString: string;
|
||||
|
||||
let team: Team;
|
||||
let teammateOne: User;
|
||||
let teammateTwo: User;
|
||||
let collectiveEventTypeId: number;
|
||||
let collectiveEventTypeWithoutHostsId: number;
|
||||
let roundRobinEventTypeId: number;
|
||||
let collectiveBookingId: number;
|
||||
let roundRobinBookingId: number;
|
||||
let fullyBookedRoundRobinBookingIdOne: number;
|
||||
let fullyBookedRoundRobinBookingIdTwo: number;
|
||||
|
||||
let reservedSlot: ReserveSlotOutputData_2024_09_04;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule_2024_06_11,
|
||||
SlotsModule_2024_09_04,
|
||||
],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
|
||||
|
||||
teammateOne = await userRepositoryFixture.create({
|
||||
email: teammateEmailOne,
|
||||
name: teammateEmailOne,
|
||||
username: teammateEmailOne,
|
||||
});
|
||||
|
||||
teammateTwo = await userRepositoryFixture.create({
|
||||
email: teammateEmailTwo,
|
||||
name: teammateEmailTwo,
|
||||
username: teammateEmailTwo,
|
||||
});
|
||||
|
||||
outsider = await userRepositoryFixture.create({
|
||||
email: outsiderEmail,
|
||||
name: outsiderEmail,
|
||||
username: outsiderEmail,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
|
||||
teammateApiKeyString = keyString;
|
||||
|
||||
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
outsider.id,
|
||||
null
|
||||
);
|
||||
outsiderApiKeyString = unrelatedUserKeyString;
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `slots-2024-09-04-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammateOne.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammateTwo.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: `slots-2024-09-04-collective-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teammateOne.id,
|
||||
isFixed: true,
|
||||
},
|
||||
{
|
||||
userId: teammateTwo.id,
|
||||
isFixed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
collectiveEventTypeId = collectiveEventType.id;
|
||||
|
||||
const collectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type Without Hosts",
|
||||
slug: `slots-2024-09-04-collective-event-type-without-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
});
|
||||
collectiveEventTypeWithoutHostsId = collectiveEventTypeWithoutHosts.id;
|
||||
|
||||
const roundRobinEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "RR Event Type",
|
||||
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teammateOne.id,
|
||||
isFixed: true,
|
||||
},
|
||||
{
|
||||
userId: teammateTwo.id,
|
||||
isFixed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
roundRobinEventTypeId = roundRobinEventType.id;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
|
||||
await schedulesService.createUserSchedule(teammateOne.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(teammateTwo.id, userSchedule);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should get collective team event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able reserve a team event type slot with custom duration if no auth is provided", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId: collectiveEventTypeId,
|
||||
slotStart: "2050-09-05T10:00:00.000Z",
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should not be able reserve a slot with custom duration if provided auth user is not part of the team that owns the team event type", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId: collectiveEventTypeId,
|
||||
slotStart: "2050-09-05T10:00:00.000Z",
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not be able reserve a slot for team event type without hosts", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId: collectiveEventTypeWithoutHostsId,
|
||||
slotStart: "2050-09-05T10:00:00.000Z",
|
||||
})
|
||||
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should reserve a slot as team member of the team that owns the team event type", async () => {
|
||||
// note(Lauris): mock current date to test slots release time
|
||||
const now = "2049-09-05T12:00:00.000Z";
|
||||
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
|
||||
advanceTo(newDate);
|
||||
|
||||
const slotStartTime = "2050-09-05T10:00:00.000Z";
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
|
||||
.send({
|
||||
eventTypeId: collectiveEventTypeId,
|
||||
slotStart: slotStartTime,
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
|
||||
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
|
||||
expect(responseReservedSlot.reservationUid).toBeDefined();
|
||||
if (!responseReservedSlot.reservationUid) {
|
||||
throw new Error("Reserved slot uid is undefined");
|
||||
}
|
||||
reservedSlot = responseReservedSlot;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
(slot) => slot.start !== slotStartTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
|
||||
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
|
||||
expect(dbSlot).toBeDefined();
|
||||
if (dbSlot) {
|
||||
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
|
||||
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
|
||||
expect(dbReleaseAt).toEqual(expectedReleaseAt);
|
||||
}
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
|
||||
clear();
|
||||
});
|
||||
|
||||
it("should book collective event type and slot should not be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${collectiveEventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: collectiveEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teammateOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
collectiveBookingId = booking.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
(slot) => slot.start !== startTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should book round robin event type and slot should be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${roundRobinEventTypeId}`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: roundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teammateOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
roundRobinBookingId = booking.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
(slot) => slot.start !== startTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fully book round robin event type and slot should not be available at that time", async () => {
|
||||
const startTime = "2050-09-05T11:00:00.000Z";
|
||||
const bookingOne = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${roundRobinEventTypeId}-1`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: roundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teammateOne.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
fullyBookedRoundRobinBookingIdOne = bookingOne.id;
|
||||
|
||||
const bookingTwo = await bookingsRepositoryFixture.create({
|
||||
uid: `booking-uid-${roundRobinEventTypeId}-2`,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: roundRobinEventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teammateTwo.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
fullyBookedRoundRobinBookingIdTwo = bookingTwo.id;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
||||
(slot) => slot.start !== startTime
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
bookingsRepositoryFixture.deleteById(bookingOne.id);
|
||||
bookingsRepositoryFixture.deleteById(bookingTwo.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teammateOne.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
|
||||
await userRepositoryFixture.deleteByEmail(outsiderEmail);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await bookingsRepositoryFixture.deleteById(collectiveBookingId);
|
||||
await bookingsRepositoryFixture.deleteById(roundRobinBookingId);
|
||||
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdOne);
|
||||
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdTwo);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+1260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
import { VERSION_2024_09_04 } from "@/lib/api-versions";
|
||||
import { GetOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
import { GetReservedSlotOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-reserved-slot.output";
|
||||
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
||||
import { ReserveSlotOutputResponse_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/reserve-slot.output";
|
||||
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
|
||||
import {
|
||||
Query,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Delete,
|
||||
Post,
|
||||
Param,
|
||||
Res,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiTags as DocsTags,
|
||||
ApiHeader,
|
||||
ApiResponse as DocsResponse,
|
||||
ApiQuery,
|
||||
} from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
GetSlotsInput_2024_09_04,
|
||||
GetSlotsInputPipe,
|
||||
ReserveSlotInput_2024_09_04,
|
||||
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputType_2024_09_04,
|
||||
GetReservedSlotOutput_2024_09_04 as GetReservedSlotOutputType_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/slots",
|
||||
version: VERSION_2024_09_04,
|
||||
})
|
||||
@DocsTags("Slots")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to \`2024-09-04\``,
|
||||
required: true,
|
||||
})
|
||||
export class SlotsController_2024_09_04 {
|
||||
constructor(private readonly slotsService: SlotsService_2024_09_04) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
description: `
|
||||
There are 4 ways to get available slots:
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.
|
||||
|
||||
All of them require "start" and "end" query parameters which define the time range for which available slots should be checked.
|
||||
Optional parameters are:
|
||||
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
|
||||
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
|
||||
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
`,
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "timeZone",
|
||||
required: false,
|
||||
description: "Time zone in which the available slots should be returned. Defaults to UTC.",
|
||||
example: "Europe/Rome",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "duration",
|
||||
required: false,
|
||||
description:
|
||||
"If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.",
|
||||
example: "60",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "slotFormat",
|
||||
required: false,
|
||||
description:
|
||||
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
|
||||
example: "range",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "usernames",
|
||||
required: false,
|
||||
description: `The usernames for which available slots should be checked separated by a comma.
|
||||
|
||||
Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.
|
||||
|
||||
Must contain at least 2 usernames.`,
|
||||
example: "alice,bob",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "eventTypeId",
|
||||
required: false,
|
||||
description: "The ID of the event type for which available slots should be checked.",
|
||||
example: "100",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "eventTypeSlug",
|
||||
required: false,
|
||||
description:
|
||||
"The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
|
||||
example: "event-type-slug",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "username",
|
||||
required: false,
|
||||
description: "The username of the user to get event types for.",
|
||||
example: "bob",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "end",
|
||||
required: true,
|
||||
description: `
|
||||
Time until which available slots should be checked.
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
You can pass date without hours which defaults to end of day or specify hours:
|
||||
2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.`,
|
||||
example: "2050-09-06",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "start",
|
||||
required: true,
|
||||
description: `
|
||||
Time starting from which available slots should be checked.
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
You can pass date without hours which defaults to start of day or specify hours:
|
||||
2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.`,
|
||||
example: "2050-09-05",
|
||||
})
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
|
||||
For seated slots each object will have attendeesCount and bookingUid properties.`,
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
title: "Default format (or with format=time)",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
status: "success",
|
||||
data: {
|
||||
"2050-09-05": [
|
||||
{ start: "2050-09-05T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T10:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-06": [
|
||||
{ start: "2050-09-06T09:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T10:00:00.000+02:00" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
title: "Range format (when format=range)",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
start: { type: "string" },
|
||||
end: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
example: {
|
||||
status: "success",
|
||||
data: {
|
||||
"2050-09-05": [
|
||||
{ start: "2050-09-05T09:00:00.000+02:00", end: "2050-09-05T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-05T10:00:00.000+02:00", end: "2050-09-05T11:00:00.000+02:00" },
|
||||
],
|
||||
"2050-09-06": [
|
||||
{ start: "2050-09-06T09:00:00.000+02:00", end: "2050-09-06T10:00:00.000+02:00" },
|
||||
{ start: "2050-09-06T10:00:00.000+02:00", end: "2050-09-06T11:00:00.000+02:00" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async getAvailableSlots(
|
||||
@Query(new GetSlotsInputPipe()) query: GetSlotsInput_2024_09_04
|
||||
): Promise<GetSlotsOutput_2024_09_04> {
|
||||
const slots = await this.slotsService.getAvailableSlots(query);
|
||||
|
||||
return {
|
||||
data: slots,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/reservations")
|
||||
@UseGuards(OptionalApiAuthGuard)
|
||||
@ApiOperation({
|
||||
summary: "Reserve a slot",
|
||||
description: "Make a slot not available for others to book for a certain period of time.",
|
||||
})
|
||||
async reserveSlot(
|
||||
@Body() body: ReserveSlotInput_2024_09_04,
|
||||
@GetOptionalUser() user: User
|
||||
): Promise<ReserveSlotOutputResponse_2024_09_04> {
|
||||
const reservedSlot = await this.slotsService.reserveSlot(body, user?.id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(ReserveSlotOutputType_2024_09_04, reservedSlot, {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Get reserved slot",
|
||||
})
|
||||
async getReservedSlot(@Param("uid") uid: string): Promise<GetReservedSlotOutput_2024_09_04> {
|
||||
const reservedSlot = await this.slotsService.getReservedSlot(uid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(GetReservedSlotOutputType_2024_09_04, reservedSlot, {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Updated reserved a slot",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateReservedSlot(
|
||||
@Body() body: ReserveSlotInput_2024_09_04,
|
||||
@Param("uid") uid: string
|
||||
): Promise<ReserveSlotOutputResponse_2024_09_04> {
|
||||
const reservedSlot = await this.slotsService.updateReservedSlot(body, uid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(ReserveSlotOutputType_2024_09_04, reservedSlot, {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/reservations/:uid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
schema: {
|
||||
type: "object",
|
||||
example: {
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
})
|
||||
async deleteReservedSlot(@Param("uid") uid: string): Promise<ApiResponse> {
|
||||
await this.slotsService.deleteReservedSlot(uid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import {
|
||||
ApiResponseWithoutData,
|
||||
GetReservedSlotOutput_2024_09_04 as GetReservedSlotOutputType_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export class GetReservedSlotOutput_2024_09_04 extends ApiResponseWithoutData {
|
||||
@ApiProperty({
|
||||
type: GetReservedSlotOutputType_2024_09_04,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => GetReservedSlotOutputType_2024_09_04)
|
||||
data!: GetReservedSlotOutputType_2024_09_04 | null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, SlotsOutput_2024_09_04 } from "@calcom/platform-types";
|
||||
import { RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
|
||||
export class GetSlotsOutput_2024_09_04 extends ApiResponseWithoutData {
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(SlotsOutput_2024_09_04) },
|
||||
{ $ref: getSchemaPath(RangeSlotsOutput_2024_09_04) },
|
||||
],
|
||||
})
|
||||
@Type(() => Object)
|
||||
data!: SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import {
|
||||
ApiResponseWithoutData,
|
||||
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputType_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export class ReserveSlotOutputResponse_2024_09_04 extends ApiResponseWithoutData {
|
||||
@ApiProperty({
|
||||
type: ReserveSlotOutputType_2024_09_04,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ReserveSlotOutputType_2024_09_04)
|
||||
data!: ReserveSlotOutputType_2024_09_04;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { dynamicEvent } from "@calcom/platform-libraries";
|
||||
import { GetSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SlotsInputService_2024_09_04 {
|
||||
constructor(
|
||||
private readonly eventTypeRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04) {
|
||||
const eventType = await this.getEventType(query);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event Type not found`);
|
||||
}
|
||||
const isTeamEvent = !!eventType?.teamId;
|
||||
|
||||
const startTime = query.start;
|
||||
const endTime = this.adjustEndTime(query.end);
|
||||
const duration = query.duration;
|
||||
const eventTypeId = eventType.id;
|
||||
const eventTypeSlug = eventType.slug;
|
||||
const usernameList = "usernames" in query ? query.usernames : [];
|
||||
const timeZone = query.timeZone;
|
||||
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
|
||||
|
||||
return {
|
||||
isTeamEvent,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
usernameList,
|
||||
timeZone,
|
||||
orgSlug,
|
||||
};
|
||||
}
|
||||
|
||||
private async getEventType(input: GetSlotsInput_2024_09_04) {
|
||||
if ("eventTypeId" in input) {
|
||||
return this.eventTypeRepository.getEventTypeById(input.eventTypeId);
|
||||
}
|
||||
|
||||
if ("eventTypeSlug" in input) {
|
||||
const user = await this.getEventTypeUser(input);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username ${input.username} not found`);
|
||||
}
|
||||
return this.eventTypeRepository.getUserEventTypeBySlug(user.id, input.eventTypeSlug);
|
||||
}
|
||||
|
||||
return input.duration ? { ...dynamicEvent, length: input.duration } : dynamicEvent;
|
||||
}
|
||||
|
||||
private async getEventTypeUser(input: GetSlotsInput_2024_09_04) {
|
||||
if ("eventTypeSlug" in input) {
|
||||
if ("organizationSlug" in input && input.organizationSlug) {
|
||||
const organization = await this.organizationsRepository.findOrgBySlug(input.organizationSlug);
|
||||
if (!organization) {
|
||||
throw new NotFoundException(
|
||||
`slots-input.service.ts: Organization with slug ${input.organizationSlug} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.organizationsUsersRepository.getOrganizationUserByUsername(
|
||||
organization.id,
|
||||
input.username
|
||||
);
|
||||
}
|
||||
|
||||
return await this.usersRepository.findByUsername(input.username);
|
||||
}
|
||||
}
|
||||
|
||||
private adjustEndTime(endTime: string) {
|
||||
let dateTime = DateTime.fromISO(endTime, { zone: "utc" });
|
||||
if (dateTime.hour === 0 && dateTime.minute === 0 && dateTime.second === 0) {
|
||||
dateTime = dateTime.set({ hour: 23, minute: 59, second: 59 });
|
||||
}
|
||||
|
||||
return dateTime.toISO();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { SlotFormat } from "@calcom/platform-enums";
|
||||
import {
|
||||
GetReservedSlotOutput_2024_09_04,
|
||||
RangeSlot_2024_09_04,
|
||||
RangeSlotsOutput_2024_09_04,
|
||||
ReserveSlotOutput_2024_09_04,
|
||||
SeatedRangeSlot_2024_09_04,
|
||||
SeatedSlot_2024_09_04,
|
||||
Slot_2024_09_04,
|
||||
SlotsOutput_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
import { SelectedSlots } from "@calcom/prisma/client";
|
||||
|
||||
type GetAvailableSlots = {
|
||||
slots: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SlotsOutputService_2024_09_04 {
|
||||
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_06_14) {}
|
||||
|
||||
async getAvailableSlots(
|
||||
availableSlots: GetAvailableSlots,
|
||||
eventTypeId: number,
|
||||
duration?: number,
|
||||
format?: SlotFormat,
|
||||
timeZone?: string
|
||||
): Promise<SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04> {
|
||||
if (!format || format === SlotFormat.Time) {
|
||||
return this.getAvailableTimeSlots(availableSlots, eventTypeId, timeZone);
|
||||
}
|
||||
|
||||
return this.getAvailableRangeSlots(availableSlots, eventTypeId, timeZone, duration);
|
||||
}
|
||||
|
||||
private async getAvailableTimeSlots(
|
||||
availableSlots: GetAvailableSlots,
|
||||
eventTypeId: number,
|
||||
timeZone: string | undefined
|
||||
): Promise<SlotsOutput_2024_09_04> {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
|
||||
|
||||
const slots: { [key: string]: (Slot_2024_09_04 | SeatedSlot_2024_09_04)[] } = {};
|
||||
for (const date in availableSlots.slots) {
|
||||
slots[date] = availableSlots.slots[date].map((slot) => {
|
||||
if (!timeZone) {
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableTimeSlot(slot.time);
|
||||
}
|
||||
return this.getAvailableTimeSlotSeated(
|
||||
slot.time,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot || 0,
|
||||
slot.bookingUid
|
||||
);
|
||||
}
|
||||
const slotTimezoneAdjusted = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
|
||||
if (!slotTimezoneAdjusted) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableTimeSlot(slotTimezoneAdjusted);
|
||||
}
|
||||
return this.getAvailableTimeSlotSeated(
|
||||
slotTimezoneAdjusted,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot || 0,
|
||||
slot.bookingUid
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
private getAvailableTimeSlot(start: string): Slot_2024_09_04 | SeatedSlot_2024_09_04 {
|
||||
return {
|
||||
start,
|
||||
};
|
||||
}
|
||||
|
||||
private getAvailableTimeSlotSeated(
|
||||
start: string,
|
||||
seatsBooked: number,
|
||||
eventTypeSeatsPerTimeslot: number,
|
||||
bookingUid: string | undefined
|
||||
): Slot_2024_09_04 | SeatedSlot_2024_09_04 {
|
||||
return {
|
||||
start,
|
||||
seatsBooked,
|
||||
seatsRemaining: eventTypeSeatsPerTimeslot - seatsBooked,
|
||||
seatsTotal: eventTypeSeatsPerTimeslot,
|
||||
bookingUid,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAvailableRangeSlots(
|
||||
availableSlots: GetAvailableSlots,
|
||||
eventTypeId: number,
|
||||
timeZone?: string,
|
||||
duration?: number
|
||||
): Promise<RangeSlotsOutput_2024_09_04> {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
|
||||
|
||||
const slotDuration = duration ?? eventType?.length;
|
||||
|
||||
const slots = Object.entries(availableSlots.slots).reduce<
|
||||
Record<string, (RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04)[]>
|
||||
>((acc, [date, slots]) => {
|
||||
acc[date] = slots.map((slot) => {
|
||||
if (timeZone) {
|
||||
const start = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
|
||||
if (!start) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
|
||||
const end = DateTime.fromISO(slot.time, { zone: "utc" })
|
||||
.plus({ minutes: slotDuration })
|
||||
.setZone(timeZone)
|
||||
.toISO();
|
||||
|
||||
if (!end) {
|
||||
throw new BadRequestException(
|
||||
`Could not adjust timezone for slot end time ${slot.time} with timezone ${timeZone}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableRangeSlot(start, end);
|
||||
}
|
||||
return this.getAvailableRangeSlotSeated(
|
||||
start,
|
||||
end,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot ?? undefined,
|
||||
slot.bookingUid
|
||||
);
|
||||
} else {
|
||||
const start = DateTime.fromISO(slot.time, { zone: "utc" }).toISO();
|
||||
const end = DateTime.fromISO(slot.time, { zone: "utc" }).plus({ minutes: slotDuration }).toISO();
|
||||
|
||||
if (!start || !end) {
|
||||
throw new BadRequestException(`Could not create UTC time for slot ${slot.time}`);
|
||||
}
|
||||
|
||||
if (!eventType?.seatsPerTimeSlot) {
|
||||
return this.getAvailableRangeSlot(start, end);
|
||||
}
|
||||
return this.getAvailableRangeSlotSeated(
|
||||
start,
|
||||
end,
|
||||
slot.attendees || 0,
|
||||
eventType.seatsPerTimeSlot ?? undefined,
|
||||
slot.bookingUid
|
||||
);
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
private getAvailableRangeSlot(
|
||||
start: string,
|
||||
end: string
|
||||
): RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04 {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
private getAvailableRangeSlotSeated(
|
||||
start: string,
|
||||
end: string,
|
||||
seatsBooked: number,
|
||||
eventTypeSeatsPerTimeslot: number,
|
||||
bookingUid: string | undefined
|
||||
): RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04 {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
seatsBooked,
|
||||
seatsRemaining: eventTypeSeatsPerTimeslot - seatsBooked,
|
||||
seatsTotal: eventTypeSeatsPerTimeslot,
|
||||
bookingUid,
|
||||
};
|
||||
}
|
||||
|
||||
getReservationSlot(slot: SelectedSlots): GetReservedSlotOutput_2024_09_04 {
|
||||
return {
|
||||
eventTypeId: slot.eventTypeId,
|
||||
slotStart: DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }).toISO() || "unknown-slot-start",
|
||||
slotEnd: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).toISO() || "unknown-slot-end",
|
||||
slotDuration: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).diff(
|
||||
DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }),
|
||||
"minutes"
|
||||
).minutes,
|
||||
reservationUid: slot.uid,
|
||||
reservationUntil:
|
||||
DateTime.fromJSDate(slot.releaseAt, { zone: "utc" }).toISO() || "unknown-reserved-until",
|
||||
};
|
||||
}
|
||||
|
||||
getReservationSlotCreated(slot: SelectedSlots, reservationDuration: number): ReserveSlotOutput_2024_09_04 {
|
||||
return {
|
||||
eventTypeId: slot.eventTypeId,
|
||||
slotStart: DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }).toISO() || "unknown-slot-start",
|
||||
slotEnd: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).toISO() || "unknown-slot-end",
|
||||
slotDuration: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).diff(
|
||||
DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }),
|
||||
"minutes"
|
||||
).minutes,
|
||||
reservationDuration,
|
||||
reservationUid: slot.uid,
|
||||
reservationUntil:
|
||||
DateTime.fromJSDate(slot.releaseAt, { zone: "utc" }).toISO() || "unknown-reserved-until",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
|
||||
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
|
||||
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries";
|
||||
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
const eventTypeMetadataSchema = z.object({
|
||||
multipleDuration: z.number().array().optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_RESERVATION_DURATION = 5;
|
||||
|
||||
@Injectable()
|
||||
export class SlotsService_2024_09_04 {
|
||||
constructor(
|
||||
private readonly eventTypeRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly slotsRepository: SlotsRepository_2024_09_04,
|
||||
private readonly slotsOutputService: SlotsOutputService_2024_09_04,
|
||||
private readonly slotsInputService: SlotsInputService_2024_09_04,
|
||||
private readonly membershipsService: MembershipsService,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly teamsRepository: TeamsRepository
|
||||
) {}
|
||||
|
||||
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
const formatted = await this.slotsOutputService.getAvailableSlots(
|
||||
availableSlots,
|
||||
queryTransformed.eventTypeId,
|
||||
queryTransformed.duration,
|
||||
query.format,
|
||||
queryTransformed.timeZone
|
||||
);
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
|
||||
if (input.reservationDuration && !authUserId) {
|
||||
throw new UnauthorizedException(
|
||||
"reservationDuration can only be used for authenticated requests - use access token, api key or OAuth credentials"
|
||||
);
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypeRepository.getEventTypeWithHosts(input.eventTypeId);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event Type with ID=${input.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
if (input.reservationDuration && authUserId) {
|
||||
const canSpecifyCustomReservationDuration = await this.canSpecifyCustomReservationDuration(
|
||||
authUserId,
|
||||
eventType
|
||||
);
|
||||
if (!canSpecifyCustomReservationDuration) {
|
||||
throw new ForbiddenException(
|
||||
"authenticated user is not owner of event type, does not have memberships in common with owner of the event type, nor does belong to event type's team or org."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startDate = DateTime.fromISO(input.slotStart, { zone: "utc" });
|
||||
if (!startDate.isValid) {
|
||||
throw new BadRequestException("Invalid start date");
|
||||
}
|
||||
|
||||
const metadata = eventTypeMetadataSchema.parse(eventType);
|
||||
if (
|
||||
input.slotDuration &&
|
||||
metadata.multipleDuration &&
|
||||
!metadata.multipleDuration.includes(input.slotDuration)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });
|
||||
if (!endDate.isValid) {
|
||||
throw new BadRequestException("Invalid end date");
|
||||
}
|
||||
|
||||
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
|
||||
input.eventTypeId,
|
||||
startDate.toJSDate()
|
||||
);
|
||||
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const attendeesCount = booking?.attendees?.length;
|
||||
if (attendeesCount) {
|
||||
const seatsLeft = eventType.seatsPerTimeSlot - attendeesCount;
|
||||
if (seatsLeft < 1) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Booking with id=${input.eventTypeId} at ${input.slotStart} has no more seats left.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
|
||||
if (nonSeatedEventAlreadyBooked) {
|
||||
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
|
||||
}
|
||||
|
||||
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
|
||||
|
||||
if (eventType.userId) {
|
||||
const slot = await this.slotsRepository.createSlot(
|
||||
eventType.userId,
|
||||
eventType.id,
|
||||
startDate.toISO(),
|
||||
endDate.toISO(),
|
||||
eventType.seatsPerTimeSlot !== null,
|
||||
reservationDuration
|
||||
);
|
||||
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
|
||||
}
|
||||
|
||||
const host = eventType.hosts[0];
|
||||
if (!host) {
|
||||
throw new BadRequestException("Cannot reserve a slot for a team event without any hosts");
|
||||
}
|
||||
|
||||
const slot = await this.slotsRepository.createSlot(
|
||||
host.userId,
|
||||
eventType.id,
|
||||
startDate.toISO(),
|
||||
endDate.toISO(),
|
||||
eventType.seatsPerTimeSlot !== null,
|
||||
reservationDuration
|
||||
);
|
||||
|
||||
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
|
||||
}
|
||||
|
||||
async canSpecifyCustomReservationDuration(authUserId: number, eventType: EventType) {
|
||||
if (eventType.userId) {
|
||||
return await this.canSpecifyCustomReservationDurationIndividualEvent(authUserId, eventType.userId);
|
||||
}
|
||||
if (eventType.teamId) {
|
||||
return await this.canSpecifyCustomReservationDurationTeamEvent(authUserId, eventType.teamId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async canSpecifyCustomReservationDurationIndividualEvent(authUserId: number, eventTypeOwnerId: number) {
|
||||
if (authUserId === eventTypeOwnerId) return true;
|
||||
if (await this.membershipsService.haveMembershipsInCommon(authUserId, eventTypeOwnerId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async canSpecifyCustomReservationDurationTeamEvent(authUserId: number, teamId: number) {
|
||||
const teamMembership = await this.membershipsRepository.findMembershipByTeamId(teamId, authUserId);
|
||||
const hasAcceptedTeamMembership = !!teamMembership?.accepted;
|
||||
if (hasAcceptedTeamMembership) return true;
|
||||
|
||||
const team = await this.teamsRepository.getById(teamId);
|
||||
if (!team?.parentId) {
|
||||
return false;
|
||||
}
|
||||
const orgMembership = await this.membershipsRepository.findMembershipByTeamId(team.parentId, authUserId);
|
||||
const hasAcceptedOrgMembership = !!orgMembership?.accepted;
|
||||
return hasAcceptedOrgMembership;
|
||||
}
|
||||
|
||||
async getReservedSlot(uid: string) {
|
||||
const slot = await this.slotsRepository.getByUid(uid);
|
||||
if (!slot) {
|
||||
return null;
|
||||
}
|
||||
return this.slotsOutputService.getReservationSlot(slot);
|
||||
}
|
||||
|
||||
async updateReservedSlot(input: ReserveSlotInput_2024_09_04, uid: string) {
|
||||
const dbSlot = await this.slotsRepository.getByUid(uid);
|
||||
if (!dbSlot) {
|
||||
throw new NotFoundException(`Slot with uid=${uid} not found`);
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypeRepository.getEventTypeWithHosts(input.eventTypeId);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event Type with ID=${input.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const startDate = DateTime.fromISO(input.slotStart, { zone: "utc" });
|
||||
if (!startDate.isValid) {
|
||||
throw new BadRequestException("Invalid start date");
|
||||
}
|
||||
|
||||
const metadata = eventTypeMetadataSchema.parse(eventType);
|
||||
if (
|
||||
input.slotDuration &&
|
||||
metadata.multipleDuration &&
|
||||
!metadata.multipleDuration.includes(input.slotDuration)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });
|
||||
if (!endDate.isValid) {
|
||||
throw new BadRequestException("Invalid end date");
|
||||
}
|
||||
|
||||
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
|
||||
input.eventTypeId,
|
||||
startDate.toJSDate()
|
||||
);
|
||||
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const attendeesCount = booking?.attendees?.length;
|
||||
if (attendeesCount) {
|
||||
const seatsLeft = eventType.seatsPerTimeSlot - attendeesCount;
|
||||
if (seatsLeft < 1) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Booking with id=${input.eventTypeId} at ${input.slotStart} has no more seats left.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
|
||||
if (nonSeatedEventAlreadyBooked) {
|
||||
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
|
||||
}
|
||||
|
||||
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
|
||||
|
||||
const slot = await this.slotsRepository.updateSlot(
|
||||
eventType.id,
|
||||
startDate.toISO(),
|
||||
endDate.toISO(),
|
||||
dbSlot.id,
|
||||
reservationDuration
|
||||
);
|
||||
|
||||
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
|
||||
}
|
||||
|
||||
async deleteReservedSlot(uid: string) {
|
||||
return this.slotsRepository.deleteSlot(uid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SlotsController_2024_09_04 } from "@/modules/slots/slots-2024-09-04/controllers/slots.controller";
|
||||
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
|
||||
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
|
||||
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
|
||||
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14, StripeModule, TeamsModule, MembershipsModule],
|
||||
providers: [
|
||||
SlotsRepository_2024_09_04,
|
||||
SlotsService_2024_09_04,
|
||||
UsersRepository,
|
||||
SlotsInputService_2024_09_04,
|
||||
SlotsOutputService_2024_09_04,
|
||||
OrganizationsUsersRepository,
|
||||
OrganizationsRepository,
|
||||
],
|
||||
controllers: [SlotsController_2024_09_04],
|
||||
exports: [SlotsService_2024_09_04],
|
||||
})
|
||||
export class SlotsModule_2024_09_04 {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
@Injectable()
|
||||
export class SlotsRepository_2024_09_04 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getByUid(uid: string) {
|
||||
return this.dbRead.prisma.selectedSlots.findFirst({ where: { uid } });
|
||||
}
|
||||
|
||||
async getBookingWithAttendeesByEventTypeIdAndStart(eventTypeId: number, startTime: Date) {
|
||||
return this.dbRead.prisma.booking.findFirst({
|
||||
where: { eventTypeId, startTime },
|
||||
select: { attendees: true },
|
||||
});
|
||||
}
|
||||
|
||||
async createSlot(
|
||||
userId: number,
|
||||
eventTypeId: number,
|
||||
slotUtcStartDate: string,
|
||||
slotUtcEndDate: string,
|
||||
isSeat: boolean,
|
||||
duration: number
|
||||
) {
|
||||
const uid = uuid();
|
||||
const reservationUntil = DateTime.utc().plus({ minutes: duration }).toISO();
|
||||
|
||||
return this.dbWrite.prisma.selectedSlots.create({
|
||||
data: {
|
||||
uid,
|
||||
userId,
|
||||
eventTypeId,
|
||||
slotUtcStartDate,
|
||||
slotUtcEndDate,
|
||||
releaseAt: reservationUntil,
|
||||
isSeat,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSlot(
|
||||
eventTypeId: number,
|
||||
slotUtcStartDate: string,
|
||||
slotUtcEndDate: string,
|
||||
id: number,
|
||||
duration: number
|
||||
) {
|
||||
const reservationUntil = DateTime.utc().plus({ minutes: duration }).toISO();
|
||||
|
||||
return this.dbWrite.prisma.selectedSlots.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
slotUtcEndDate,
|
||||
slotUtcStartDate,
|
||||
releaseAt: reservationUntil,
|
||||
eventTypeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSlot(uid: string) {
|
||||
// note(Lauris): we have deleteMany because for some reason uid is not unique in the prisma schema
|
||||
return this.dbWrite.prisma.selectedSlots.deleteMany({
|
||||
where: { uid: { equals: uid } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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, SlotsOutputService],
|
||||
controllers: [SlotsController],
|
||||
exports: [SlotsService],
|
||||
})
|
||||
export class SlotsModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class AttendeeRepositoryFixture {
|
||||
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 create(attendee: Prisma.AttendeeCreateInput) {
|
||||
return this.prismaWriteClient.attendee.create({ data: attendee });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class BookingSeatRepositoryFixture {
|
||||
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 create(bookingSeat: Prisma.BookingSeatCreateInput) {
|
||||
return this.prismaWriteClient.bookingSeat.create({ data: bookingSeat });
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export class BookingsRepositoryFixture {
|
||||
}
|
||||
|
||||
async deleteById(bookingId: Booking["id"]) {
|
||||
return this.prismaWriteClient.booking.delete({ where: { id: bookingId } });
|
||||
return this.prismaWriteClient.booking.deleteMany({ where: { id: bookingId } });
|
||||
}
|
||||
|
||||
async deleteAllBookings(userId: User["id"], userEmail: User["email"]) {
|
||||
|
||||
@@ -12,6 +12,10 @@ export class SelectedSlotsRepositoryFixture {
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async getByUid(uid: string) {
|
||||
return this.prismaReadClient.selectedSlots.findFirst({ where: { uid } });
|
||||
}
|
||||
|
||||
async deleteByUId(uid: SelectedSlots["uid"]) {
|
||||
return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/teams/getServerSideProps";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { PageProps as ClientPageProps } from "~/teams/teams-view";
|
||||
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -16,11 +16,15 @@ export const generateMetadata = async () =>
|
||||
(t) => t("create_manage_teams_collaborative")
|
||||
);
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ searchParams }: ServerPageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) });
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
|
||||
|
||||
if (!session) {
|
||||
redirect(callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login");
|
||||
}
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const props = await getData(context);
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
@@ -28,7 +32,7 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
CTA={<TeamsCTA />}
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}>
|
||||
<TeamsView {...props} />
|
||||
<TeamsView />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,4 +32,4 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
return <SetupView {...props} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
export default ServerPage;
|
||||
|
||||
@@ -21,9 +21,9 @@ const querySchema = z.object({
|
||||
const ServerPage = async ({ searchParams }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const { error } = querySchema.parse({ error: searchParams?.error || undefined });
|
||||
const errorMsg = error || t("error_during_login");
|
||||
const errorMsg = t("error_during_login") + (error ? ` Error code: ${error}` : "");
|
||||
return (
|
||||
<AuthContainer title="" description="" isAppDir={true}>
|
||||
<AuthContainer>
|
||||
<div>
|
||||
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="x" className="h-6 w-6 text-red-600" />
|
||||
|
||||
@@ -9,4 +9,8 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
const ServerPageWrapper = async () => {
|
||||
return <Page />;
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import OldPage from "~/bookings/views/bookings-single-view";
|
||||
import {
|
||||
getServerSideProps,
|
||||
type PageProps as ClientPageProps,
|
||||
} from "~/bookings/views/bookings-single-view.getServerSideProps";
|
||||
|
||||
const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const props = await getEmbedData(context);
|
||||
return <OldPage {...props} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
+8
-2
@@ -1,7 +1,6 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
@@ -27,4 +26,11 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<PageProps>(getServerSideProps);
|
||||
export default WithLayout({ getLayout: null, Page: Type, getData })<"P">;
|
||||
const ServerPage = async ({ params, searchParams }: _PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const pageProps = await getData(legacyCtx);
|
||||
|
||||
return <Type {...pageProps} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createHmac } from "crypto";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { apiRouteMiddleware } from "@calcom/lib/server/apiRouteMiddleware";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const responseSchema = z.object({
|
||||
hash: z.string(),
|
||||
email: z.string().email(),
|
||||
@@ -15,9 +17,8 @@ const responseSchema = z.object({
|
||||
chatAvatarUrl: z.string(),
|
||||
});
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = await getServerSession({ req: request as any });
|
||||
async function handler() {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session email found" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import OldPage from "~/bookings/views/bookings-single-view";
|
||||
import { getServerSideProps, type PageProps } from "~/bookings/views/bookings-single-view.getServerSideProps";
|
||||
|
||||
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData: getEmbedData, Page: OldPage });
|
||||
@@ -122,8 +122,7 @@ async function NotFound() {
|
||||
<span className="mt-2 inline-block text-lg">{t("check_spelling_mistakes_or_go_back")}</span>
|
||||
) : IS_CALCOM ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t(`404_the_${pageType.toLowerCase()}`)}
|
||||
|
||||
{t(`404_the_${pageType.toLowerCase()}`)}{" "}
|
||||
{username ? (
|
||||
<>
|
||||
<strong className="text-blue-500">{username}</strong>
|
||||
@@ -137,7 +136,7 @@ async function NotFound() {
|
||||
{t(`404_the_${pageType.toLowerCase()}`)}{" "}
|
||||
{username ? (
|
||||
<>
|
||||
<strong className="text-lgtext-green-500 mt-2 inline-block">{username}</strong>{" "}
|
||||
<strong className="mt-2 inline-block text-lg text-green-500">{username}</strong>{" "}
|
||||
{t("is_still_available")}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import CacheProvider from "react-inlinesvg/provider";
|
||||
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
<PlainChat />
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>{children}</CacheProvider>
|
||||
</TrpcProvider>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
Icon,
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
@@ -909,71 +910,73 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<s>
|
||||
<>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
</s>
|
||||
</>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!isSmsCalEmail(email) && (
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{!isSmsCalEmail(email) && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${email}`}
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<a href={`mailto:${email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${email}`}
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const isEmailCopied = isSmsCalEmail(email);
|
||||
copyToClipboard(isEmailCopied ? email : phoneNumber ?? "");
|
||||
setOpenDropdown(false);
|
||||
e.stopPropagation();
|
||||
showToast(isEmailCopied ? t("email_copied") : t("phone_number_copied"), "success");
|
||||
}}>
|
||||
<a href={`mailto:${email}`}>{t("email")}</a>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const isEmailCopied = isSmsCalEmail(email);
|
||||
copyToClipboard(isEmailCopied ? email : phoneNumber ?? "");
|
||||
setOpenDropdown(false);
|
||||
showToast(isEmailCopied ? t("email_copied") : t("phone_number_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
{noShow ? (
|
||||
<DropdownItem
|
||||
data-testid="unmark-no-show"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
|
||||
}}
|
||||
StartIcon="eye">
|
||||
{t("unmark_as_no_show")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<DropdownItem
|
||||
data-testid="mark-no-show"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
|
||||
}}
|
||||
StartIcon="eye-off">
|
||||
{t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
{noShow ? (
|
||||
<DropdownItem
|
||||
data-testid="unmark-no-show"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
|
||||
}}
|
||||
StartIcon="eye">
|
||||
{t("unmark_as_no_show")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<DropdownItem
|
||||
data-testid="mark-no-show"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
|
||||
}}
|
||||
StartIcon="eye-off">
|
||||
{t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import classNames from "classnames";
|
||||
|
||||
import { HeadSeo, Logo } from "@calcom/ui";
|
||||
import { Logo } from "@calcom/ui";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
footerText?: React.ReactNode | string;
|
||||
showLogo?: boolean;
|
||||
heading?: string;
|
||||
loading?: boolean;
|
||||
isAppDir?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
{!props.isAppDir ? <HeadSeo title={props.title} description={props.description} /> : null}
|
||||
{props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />}
|
||||
|
||||
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
@@ -266,7 +265,6 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<PlainChat />
|
||||
<CustomI18nextProvider i18n={props.i18n}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
@@ -295,7 +294,6 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<PlainChat />
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<TooltipProvider>
|
||||
<CalcomThemeProvider
|
||||
|
||||
@@ -56,7 +56,7 @@ export const buildLegacyCtx = (
|
||||
searchParams: SearchParams
|
||||
) => {
|
||||
return {
|
||||
query: { ...searchParams, ...params },
|
||||
query: { ...searchParams, ...decodeParams(params) },
|
||||
// decoding is required to be backward compatible with Pages Router
|
||||
// because Next.js App Router does not auto-decode query params while Pages Router does
|
||||
// e.g., params: { name: "John%20Doe" } => params: { name: "John Doe" }
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
export default function useIsBookingPage(): boolean {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
const isBookingPage = ["/booking", "/cancel", "/reschedule", "/d"].some((route) =>
|
||||
pathname?.startsWith(route)
|
||||
);
|
||||
|
||||
const searchParams = useCompatSearchParams();
|
||||
const userParam = Boolean(searchParams?.get("user"));
|
||||
|
||||
@@ -34,7 +34,6 @@ interface PlainChatConfig {
|
||||
chatButtons: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
type: string;
|
||||
form?: {
|
||||
fields: Array<{
|
||||
type: string;
|
||||
@@ -43,10 +42,7 @@ interface PlainChatConfig {
|
||||
icon: string;
|
||||
text: string;
|
||||
threadDetails: {
|
||||
severity: string;
|
||||
labelTypeIds: Array<string>;
|
||||
issueType: string;
|
||||
priority: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
@@ -154,17 +150,14 @@ const PlainChat = () => {
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask a question",
|
||||
type: "primary",
|
||||
},
|
||||
{
|
||||
icon: "bulb",
|
||||
text: "Send feedback",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Report an issue",
|
||||
type: "default",
|
||||
form: {
|
||||
fields: [
|
||||
{
|
||||
@@ -175,30 +168,21 @@ const PlainChat = () => {
|
||||
icon: "support",
|
||||
text: "I'm unable to use the app",
|
||||
threadDetails: {
|
||||
severity: "critical",
|
||||
issueType: "critical",
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
priority: "u",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Major functionality degraded",
|
||||
threadDetails: {
|
||||
severity: "major",
|
||||
issueType: "major",
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
priority: "h",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "bug",
|
||||
text: "Minor annoyance",
|
||||
threadDetails: {
|
||||
severity: "minor",
|
||||
issueType: "minor",
|
||||
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
|
||||
priority: "l",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -21,9 +21,6 @@ const paramsSchema = z.object({
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side:
|
||||
// 1. Check if team exists, to show 404
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
@@ -45,7 +42,90 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||
|
||||
if (!team || !team.eventTypes?.[0]) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
const eventData = team.eventTypes[0];
|
||||
const eventTypeId = eventData.id;
|
||||
const eventHostsUserData = await getUsersData(
|
||||
team.isPrivate,
|
||||
eventTypeId,
|
||||
eventData.hosts.map((h) => h.user)
|
||||
);
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const name = team.parent?.name ?? team.name ?? null;
|
||||
|
||||
const booking = rescheduleUid ? await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : null;
|
||||
const ssr = await ssrInit(context);
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
"@calcom/lib/server/getTeamMemberEmailFromCrm"
|
||||
);
|
||||
const {
|
||||
email: teamMemberEmail,
|
||||
recordType: crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventData,
|
||||
});
|
||||
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventData: {
|
||||
eventTypeId,
|
||||
entity: {
|
||||
fromRedirectOfNonOrgLink,
|
||||
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
|
||||
orgSlug,
|
||||
teamSlug: team.slug ?? null,
|
||||
name,
|
||||
},
|
||||
length: eventData.length,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventData.metadata),
|
||||
profile: {
|
||||
image: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
name,
|
||||
username: orgSlug ?? null,
|
||||
},
|
||||
title: eventData.title,
|
||||
users: eventHostsUserData,
|
||||
hidden: eventData.hidden,
|
||||
},
|
||||
booking,
|
||||
user: teamSlug,
|
||||
teamId: team.id,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
|
||||
themeBasis: null,
|
||||
orgBannerUrl: team.parent?.bannerUrl ?? "",
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
isSEOIndexable: allowSEOIndexing,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getTeamWithEventsData = async (
|
||||
teamSlug: string,
|
||||
meetingSlug: string,
|
||||
isValidOrgDomain: boolean,
|
||||
currentOrgDomain: string | null
|
||||
) => {
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
@@ -107,32 +187,22 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!team || !team.eventTypes?.[0]) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
const eventData = team.eventTypes[0];
|
||||
const eventTypeId = eventData.id;
|
||||
|
||||
// INFO: This code was pulled from getPublicEvent and used here.
|
||||
// Calling the tRPC fetch to get the public event data is incredibly slow
|
||||
// for large teams and we don't want to add it back. Future refactors will happen
|
||||
// to speed up this call.
|
||||
let users: { username: string; name: string }[] = [];
|
||||
|
||||
if (!team.isPrivate && eventData.hosts.length > 0) {
|
||||
users = eventData.hosts
|
||||
.filter((host) => host.user.username)
|
||||
.map((host) => ({
|
||||
username: host.user.username ?? "",
|
||||
name: host.user.name ?? "",
|
||||
const getUsersData = async (
|
||||
isPrivateTeam: boolean,
|
||||
eventTypeId: number,
|
||||
users: Pick<User, "username" | "name">[]
|
||||
) => {
|
||||
if (!isPrivateTeam && users.length > 0) {
|
||||
return users
|
||||
.filter((user) => user.username)
|
||||
.map((user) => ({
|
||||
username: user.username ?? "",
|
||||
name: user.name ?? "",
|
||||
}));
|
||||
}
|
||||
if (!team.isPrivate && eventData.hosts.length === 0) {
|
||||
// a minimalistic version of `getOwnerFromUsersArray` in `getPublicEvent.ts`
|
||||
// backward compatibility logic for team event types that have users[] but not hosts[]
|
||||
if (!isPrivateTeam && users.length === 0) {
|
||||
const { users: data } = await prisma.eventType.findUniqueOrThrow({
|
||||
where: { id: eventTypeId },
|
||||
select: {
|
||||
@@ -146,85 +216,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
});
|
||||
|
||||
if (data.length > 0) {
|
||||
users = [
|
||||
{
|
||||
username: data[0].username ?? "",
|
||||
name: data[0].name ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
return data.length > 0
|
||||
? [
|
||||
{
|
||||
username: data[0].username ?? "",
|
||||
name: data[0].name ?? "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const name = team.parent?.name ?? team.name ?? null;
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
const ssr = await ssrInit(context);
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
"@calcom/lib/server/getTeamMemberEmailFromCrm"
|
||||
);
|
||||
const {
|
||||
email: teamMemberEmail,
|
||||
recordType: crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventData,
|
||||
});
|
||||
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventData: {
|
||||
eventTypeId,
|
||||
entity: {
|
||||
fromRedirectOfNonOrgLink,
|
||||
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
|
||||
orgSlug,
|
||||
teamSlug: team.slug ?? null,
|
||||
name,
|
||||
},
|
||||
length: eventData.length,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventData.metadata),
|
||||
profile: {
|
||||
image: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
name,
|
||||
username: orgSlug ?? null,
|
||||
},
|
||||
title: eventData.title,
|
||||
users,
|
||||
hidden: eventData.hidden,
|
||||
},
|
||||
booking,
|
||||
user: teamSlug,
|
||||
teamId: team.id,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
|
||||
themeBasis: null,
|
||||
orgBannerUrl: team.parent?.bannerUrl ?? "",
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
isSEOIndexable: allowSEOIndexing,
|
||||
},
|
||||
};
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
await ssr.viewer.me.prefetch();
|
||||
const session = await getServerSession({ req: context.req });
|
||||
const token = Array.isArray(context.query?.token) ? context.query.token[0] : context.query?.token;
|
||||
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
@@ -56,11 +56,12 @@ const IntegrationsContainer = ({
|
||||
|
||||
const handleUpdateUserDefaultConferencingApp = ({
|
||||
appSlug,
|
||||
appLink,
|
||||
onSuccessCallback,
|
||||
onErrorCallback,
|
||||
}: UpdateUsersDefaultConferencingAppParams) => {
|
||||
updateDefaultAppMutation.mutate(
|
||||
{ appSlug },
|
||||
{ appSlug, appLink },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
|
||||
@@ -73,11 +73,7 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: PagePro
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContainer
|
||||
showLogo
|
||||
title={t("reset_password")}
|
||||
description={t("change_your_password")}
|
||||
heading={!success ? t("reset_password") : undefined}>
|
||||
<AuthContainer showLogo heading={!success ? t("reset_password") : undefined}>
|
||||
{isRequestExpired && <Expired />}
|
||||
{!isRequestExpired && !success && (
|
||||
<>
|
||||
|
||||
@@ -89,9 +89,7 @@ export default function ForgotPassword(props: PageProps) {
|
||||
return (
|
||||
<AuthContainer
|
||||
showLogo
|
||||
title={!success ? t("forgot_password") : t("reset_link_sent")}
|
||||
heading={!success ? t("forgot_password") : t("reset_link_sent")}
|
||||
description={t("request_password_reset")}
|
||||
footerText={
|
||||
!success && (
|
||||
<>
|
||||
|
||||
@@ -182,9 +182,6 @@ PageProps & WithNonceProps<{}>) {
|
||||
return (
|
||||
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
|
||||
<AuthContainer
|
||||
isAppDir
|
||||
title={t("login")}
|
||||
description={t("login")}
|
||||
showLogo
|
||||
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
|
||||
footerText={
|
||||
|
||||
@@ -41,7 +41,7 @@ export function Logout(props: PageProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo isAppDir>
|
||||
<AuthContainer showLogo>
|
||||
<div className="mb-4">
|
||||
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="check" className="h-6 w-6 text-green-600" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getSortedRowModel,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
@@ -95,12 +95,19 @@ type RowData =
|
||||
};
|
||||
|
||||
function BookingsContent({ status }: BookingsProps) {
|
||||
const { data: filterQuery } = useFilterQuery();
|
||||
const { data: filterQuery, pushItemToKey } = useFilterQuery();
|
||||
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.isTeamAdminOrOwner && !filterQuery.userIds?.length) {
|
||||
setIsFiltersVisible(true);
|
||||
pushItemToKey("userIds", user?.id);
|
||||
}
|
||||
}, [user, filterQuery.status]);
|
||||
|
||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
@@ -219,17 +226,17 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
}, [query.data]);
|
||||
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
if (bookingsToday.length > 0 && status === "upcoming") {
|
||||
const merged: RowData[] = [
|
||||
{ type: "today" as const },
|
||||
...bookingsToday,
|
||||
{ type: "next" as const },
|
||||
...flatData,
|
||||
];
|
||||
return merged;
|
||||
} else {
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
const merged: RowData[] = [];
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "today" as const }, ...bookingsToday);
|
||||
}
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "next" as const }, ...flatData);
|
||||
}
|
||||
return merged;
|
||||
}, [bookingsToday, flatData, status]);
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
|
||||
@@ -1133,8 +1133,6 @@ const DisplayLocation = ({
|
||||
<p className={className}>{locationToDisplay}</p>
|
||||
);
|
||||
|
||||
Success.isBookingPage = true;
|
||||
|
||||
type RecurringBookingsProps = {
|
||||
eventType: PageProps["eventType"];
|
||||
recurringBookings: PageProps["recurringBookings"];
|
||||
|
||||
@@ -33,5 +33,3 @@ export default function Type({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Type.isBookingPage = true;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTableFilters,
|
||||
DateRangeFilter,
|
||||
ColumnFilterType,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
@@ -12,25 +19,56 @@ import {
|
||||
HighestRatedMembersTable,
|
||||
LowestRatedMembersTable,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { Download } from "@calcom/features/insights/filters/Download";
|
||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||
import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsightsBookings";
|
||||
import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function InsightsPage() {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<InsightsPageContent />
|
||||
</InsightsOrgTeamsProvider>
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
|
||||
id: "createdAt",
|
||||
title: "createdAt",
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
};
|
||||
|
||||
function InsightsPageContent() {
|
||||
const { t } = useLocale();
|
||||
const { table } = useInsightsBookings();
|
||||
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
||||
|
||||
return (
|
||||
<FiltersProvider>
|
||||
<Filters />
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
|
||||
<OrgTeamsFilter />
|
||||
<DataTableFilters.AddFilterButton table={table} />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
|
||||
<div className="grow" />
|
||||
<Download />
|
||||
<DateRangeFilter column={createdAtColumn} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="my-4 space-y-4">
|
||||
<BookingKPICards />
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -53,6 +91,6 @@ export default function InsightsPage() {
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</FiltersProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,8 @@ import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
import type { getServerSideProps } from "@lib/teams/getServerSideProps";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export const TeamsCTA = () => {
|
||||
const { t } = useLocale();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
@@ -26,7 +21,7 @@ export const TeamsCTA = () => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
function Teams(props: PageProps) {
|
||||
function Teams() {
|
||||
return <TeamsListing />;
|
||||
}
|
||||
|
||||
|
||||
@@ -297,6 +297,14 @@ const nextConfig = {
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/routing",
|
||||
destination: "/routing/forms",
|
||||
},
|
||||
{
|
||||
source: "/routing/:path*",
|
||||
destination: "/apps/routing-forms/:path*",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
@@ -528,6 +536,11 @@ const nextConfig = {
|
||||
},
|
||||
async redirects() {
|
||||
const redirects = [
|
||||
{
|
||||
source: "/apps/routing-forms",
|
||||
destination: "/routing/forms",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/api/app-store/:path*",
|
||||
destination: "/app-store/:path*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.9.9",
|
||||
"version": "4.9.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function Custom404() {
|
||||
) : (
|
||||
<span className="mt-2 inline-block text-lg">
|
||||
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
|
||||
<strong className="text-lgtext-green-500 mt-2 inline-block">{username}</strong>{" "}
|
||||
<strong className="mt-2 inline-block text-lg text-green-500">{username}</strong>{" "}
|
||||
{t("is_still_available")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import CacheProvider from "react-inlinesvg/provider";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
@@ -21,6 +22,7 @@ function MyApp(props: AppProps) {
|
||||
|
||||
return (
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<PlainChat />
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
{Component.PageWrapper ? <Component.PageWrapper {...props} /> : <Component {...pageProps} />}
|
||||
|
||||
@@ -63,6 +63,20 @@ test("check SSR and OG - User Event Type", async ({ page, users }) => {
|
||||
|
||||
todo("check SSR and OG - Team Event Type");
|
||||
|
||||
test.describe("user with a special character in the username", () => {
|
||||
test("/[user] page shouldn't 404", async ({ page, users }) => {
|
||||
const user = await users.create({ username: "franz-janßen" });
|
||||
const response = await page.goto(`/${user.username}`);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
});
|
||||
|
||||
test("/[user]/[type] page shouldn't 404", async ({ page, users }) => {
|
||||
const user = await users.create({ username: "franz-janßen" });
|
||||
const response = await page.goto(`/${user.username}/30-min`);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("free user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const free = await users.create(freeUserObj);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { setupManagedEvent } from "./lib/testUtils";
|
||||
import { localize, setupManagedEvent } from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
@@ -90,12 +92,12 @@ test.describe("Bookings", () => {
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeHidden();
|
||||
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await titleAndAttendees.locator('[data-testid="mark-no-show"]').click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await page.locator('[data-testid="mark-no-show"]').click();
|
||||
await firstGuest.click();
|
||||
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -181,11 +183,11 @@ test.describe("Bookings", () => {
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await titleAndAttendees.locator('[data-testid="mark-no-show"]').click();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await page.locator('[data-testid="mark-no-show"]').click();
|
||||
await firstGuest.click();
|
||||
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -207,6 +209,95 @@ test.describe("Bookings", () => {
|
||||
webhookReceiver.close();
|
||||
});
|
||||
});
|
||||
test("Admin bookings filtered by default", async ({ page, users, bookings }) => {
|
||||
const t = await localize("en");
|
||||
const firstUser = await users.create(
|
||||
{ name: "First", email: "first@cal.com" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teamRole: MembershipRole.ADMIN,
|
||||
}
|
||||
);
|
||||
const teamId = (await firstUser.getFirstTeamMembership()).teamId;
|
||||
const secondUser = await users.create({ name: "Second", email: "second@cal.com" });
|
||||
const thirdUser = await users.create({ name: "Third", email: "third@cal.com" });
|
||||
// Add teammates to the team
|
||||
await prisma.membership.createMany({
|
||||
data: [
|
||||
{
|
||||
teamId: teamId,
|
||||
userId: secondUser.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
teamId: teamId,
|
||||
userId: thirdUser.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
//Create a single booking for FirstUser(admin)
|
||||
const firstUserBookingFixture = await createBooking({
|
||||
title: "FirstUser as Organizer Meeting",
|
||||
bookingsFixture: bookings,
|
||||
relativeDate: 3,
|
||||
organizer: firstUser,
|
||||
organizerEventType: firstUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "Second", email: secondUser.email, timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: thirdUser.email, timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
const firstUserBooking = await firstUserBookingFixture.self();
|
||||
|
||||
//Create 2 bookings for SecondUser
|
||||
await createBooking({
|
||||
title: "SecondUser as Organizer Meeting 1",
|
||||
bookingsFixture: bookings,
|
||||
organizer: secondUser,
|
||||
relativeDate: 2,
|
||||
organizerEventType: secondUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "First", email: firstUser.email, timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: thirdUser.email, timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
await createBooking({
|
||||
title: "SecondUser as Organizer Meeting 2",
|
||||
bookingsFixture: bookings,
|
||||
organizer: secondUser,
|
||||
relativeDate: 4,
|
||||
organizerEventType: secondUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "First", email: firstUser.email, timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: thirdUser.email, timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
|
||||
//admin login
|
||||
await firstUser.apiLogin();
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())),
|
||||
page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())),
|
||||
page.goto(`/bookings/upcoming`),
|
||||
page.waitForTimeout(10000),
|
||||
page.waitForURL(`**\/upcoming?status=upcoming&userIds=${firstUser.id}`),
|
||||
]);
|
||||
|
||||
//expect only 1 booking (of admin) to be shown in list due to default filtering for admin
|
||||
const upcomingBookingsTable = page.locator('[data-testid="upcoming-bookings"]');
|
||||
const bookingListItems = upcomingBookingsTable.locator('[data-testid="booking-item"]');
|
||||
const bookingListCount = await bookingListItems.count();
|
||||
expect(bookingListCount).toBe(1);
|
||||
const firstUpcomingBooking = bookingListItems.nth(0);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
firstUpcomingBooking.locator(`text=${firstUserBooking!.title}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function createBooking({
|
||||
|
||||
@@ -205,8 +205,9 @@ test.describe("Event Types tests", () => {
|
||||
await saveEventType(page);
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator(`[data-fob-field-name="location"] input`).fill("19199999999");
|
||||
const locationInput = page.locator(`[data-fob-field-name="location"] input`);
|
||||
await locationInput.clear();
|
||||
await locationInput.fill("+19199999999");
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
@@ -220,7 +221,8 @@ test.describe("Event Types tests", () => {
|
||||
await page.locator(`text="Organizer Phone Number"`).click();
|
||||
const locationInputName = "locations[0].hostPhoneNumber";
|
||||
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
||||
await page.locator(`input[name="${locationInputName}"]`).fill("19199999999");
|
||||
await page.locator(`input[name="${locationInputName}"]`).clear();
|
||||
await page.locator(`input[name="${locationInputName}"]`).fill("+19199999999");
|
||||
|
||||
await saveEventType(page);
|
||||
await gotoBookingPage(page);
|
||||
|
||||
@@ -75,10 +75,8 @@ test.describe("Insights", async () => {
|
||||
|
||||
// go to insights page
|
||||
await page.goto("/insights");
|
||||
await page.waitForURL(/.*\/insights\?teamId=[^&]*&isAll=false.*/);
|
||||
|
||||
// expect url to have isAll and TeamId in query params
|
||||
await expect(page).toHaveURL(/.*\/insights\?teamId=[^&]*&isAll=false.*/);
|
||||
await page.locator('[data-testid^="insights-filters-false-"]').waitFor();
|
||||
});
|
||||
|
||||
test("should be able to go to insights as members", async ({ page, users }) => {
|
||||
@@ -91,10 +89,7 @@ test.describe("Insights", async () => {
|
||||
// go to insights page
|
||||
await page.goto("/insights");
|
||||
|
||||
await page.waitForURL(/.*\/insights\?userId=[^&]*&isAll=false.*/);
|
||||
|
||||
// expect url to have isAll and TeamId in query params
|
||||
await expect(page).toHaveURL(/.*\/insights\?userId=[^&]*&isAll=false.*/);
|
||||
await page.locator('[data-testid^="insights-filters-false-"]').waitFor();
|
||||
});
|
||||
|
||||
test("team select filter should have 2 teams and your account option only as member", async ({
|
||||
@@ -107,19 +102,22 @@ test.describe("Insights", async () => {
|
||||
await user.apiLogin();
|
||||
|
||||
await createTeamsAndMembership(user.id, userTwo.id);
|
||||
|
||||
// go to insights page
|
||||
await page.goto("/insights");
|
||||
await page.locator(`[data-testid=insights-filters-false-undefined-${user.id}]`).waitFor();
|
||||
|
||||
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
|
||||
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
|
||||
await page
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
await expect(
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" })
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Insights Organization should have isAll option true", async ({ users, page }) => {
|
||||
@@ -132,17 +130,20 @@ test.describe("Insights", async () => {
|
||||
await owner.apiLogin();
|
||||
|
||||
await page.goto("/insights");
|
||||
await page.locator('[data-testid^="insights-filters-true-"]').waitFor();
|
||||
|
||||
await page.getByTestId("dashboard-shell").getByText("All").nth(1).click();
|
||||
await page.locator('[data-testid^="insights-filters-true-"]').getByText("All").click();
|
||||
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(4);
|
||||
await expect(
|
||||
await page
|
||||
.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })
|
||||
.locator('input[type="checkbox"]')
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test("should have all option in team-and-self filter as admin", async ({ page, users }) => {
|
||||
test("should not have all option as admin in non-org", async ({ page, users }) => {
|
||||
const owner = await users.create();
|
||||
const member = await users.create();
|
||||
|
||||
@@ -152,16 +153,13 @@ test.describe("Insights", async () => {
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
|
||||
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
|
||||
await page
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "All" })).toHaveCount(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test("should be able to switch between teams and self profile for insights", async ({ page, users }) => {
|
||||
@@ -174,25 +172,18 @@ test.describe("Insights", async () => {
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
// get div from team select filter with this class flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1
|
||||
await page.getByTestId("dashboard-shell").getByText("Team: test-insights").click();
|
||||
await page
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
await expect(await page.locator('[data-testid="org-teams-filter-item"]')).toHaveCount(3);
|
||||
|
||||
// switch to self profile
|
||||
await page.getByTestId("dashboard-shell").getByText("Your Account").click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "Your account" }).click();
|
||||
|
||||
// switch to team 1
|
||||
await page.getByTestId("dashboard-shell").getByText("test-insights").nth(0).click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights" }).first().click();
|
||||
|
||||
// switch to team 2
|
||||
await page.getByTestId("dashboard-shell").getByText("test-insights-2").click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]', { hasText: "test-insights-2" }).click();
|
||||
});
|
||||
|
||||
test("should be able to switch between memberUsers", async ({ page, users }) => {
|
||||
@@ -205,36 +196,39 @@ test.describe("Insights", async () => {
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
await page.getByText("Add filter").click();
|
||||
// Choose a team
|
||||
await page.getByTestId("dashboard-shell").getByText("Your account").click();
|
||||
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "User" }).click();
|
||||
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
|
||||
// await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
|
||||
await page.waitForSelector('[data-testid="people-filter-button"]');
|
||||
await page.click('[data-testid="people-filter-button"]');
|
||||
// Choose User filter item from dropdown
|
||||
await page.getByTestId("add-filter-button").click();
|
||||
await page.getByTestId("add-filter-item-bookingUserId").click();
|
||||
|
||||
// Wait for the URL to include bookingUserId
|
||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await page.getByTestId("filter-popover-trigger-bookingUserId").click();
|
||||
|
||||
await page
|
||||
// .locator(
|
||||
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
// )
|
||||
.locator('[data-testid="people-filter-group-option-0"]')
|
||||
// .nth(0)
|
||||
.locator('[data-testid="single-select-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(0)
|
||||
.click();
|
||||
|
||||
await page
|
||||
// .locator(
|
||||
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
// )
|
||||
.locator('[data-testid="people-filter-group-option-1"]')
|
||||
// .nth(1)
|
||||
.locator('[data-testid="single-select-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "Clear" }).click();
|
||||
await page.getByTestId("clear-filters-button").click();
|
||||
|
||||
// expect for "Team: test-insight" text in page
|
||||
await expect(page.locator("text=Team: test-insights")).toBeVisible();
|
||||
await expect(page.url()).not.toContain("bookingUserId");
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -709,7 +709,7 @@
|
||||
"apply": "適用",
|
||||
"cancel_event": "このイベントをキャンセル",
|
||||
"continue": "続行",
|
||||
"confirm": "確認",
|
||||
"confirm": "確定",
|
||||
"confirm_all": "すべて確認",
|
||||
"disband_team": "チームを解散する",
|
||||
"disband_team_confirmation_message": "このチームを解散しますか? あなたがこのチームのリンクを共有した人は、それを使って予約することができなくなります。",
|
||||
|
||||
+2806
-892
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,10 @@ Below is a list of props that can be passed to the availability settings atom.
|
||||
| onDeleteSuccess | No | A callback function that gets triggered when the user availability is deleted successfully |
|
||||
| onDeleteError | No | A callback function that gets triggered when the user availability fails to delete |
|
||||
| enableOverrides | No | Allows user to enable or disable date overrides display in the atom; defaults to disabled |
|
||||
| disableEditableHeading | No | Prevents users from editing the heading |
|
||||
| allowDelete | No | When set to false, this prop hides the delete button |
|
||||
| allowSetToDefault | No | When set to false, this prop hides the set to default toggle |
|
||||
| disableToasts | No | Allows users to enable or disable toast notifications, with the default setting being disabled. |
|
||||
|
||||
Along with the props, Availability settings atom accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.
|
||||
<p></p>
|
||||
|
||||
@@ -116,7 +116,10 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| onReserveSlotSuccess | No | Callback function for successful slot reservation |
|
||||
| onReserveSlotError | No | Callback function triggered on slot reservation failure |
|
||||
| onDeleteSlotSuccess | No | Callback function for successful slot deletion |
|
||||
| onDeleteSlotError | No | Callback function triggered on slot deletion failure
|
||||
| onDeleteSlotError | No | Callback function triggered on slot deletion failure
|
||||
| view | No | Specifies the layout of the booker atom into column, week, or month view |
|
||||
| metadata | No | Used to pass custom metadata values into the booker. Metadata should be an object eg: `{ bookingSource: "website", userRole: "admin" }` |
|
||||
| bannerUrl | No | Adds custom banner to the booker atom |
|
||||
|
||||
Along with the props, booker atom accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.
|
||||
<p></p>
|
||||
|
||||
@@ -85,12 +85,12 @@ Customizations can be done to the destination calendar settings atom via props.
|
||||
The selected calendar settings atom lets you select which calendars you want to check for conflicts to prevent double bookings. Below code snippet can be used to render the selected calendar settings atom.
|
||||
|
||||
```js
|
||||
import { SelectedCalendarSettings } from "@calcom/atoms";
|
||||
import { SelectedCalendarsSettings } from "@calcom/atoms";
|
||||
|
||||
export default function SelectedCalendars( props : SelectedCalendarsProps ) {
|
||||
return (
|
||||
<>
|
||||
<SelectedCalendarSettings classNames="mx-5 mb-6" />
|
||||
<SelectedCalendarsSettings classNames="mx-5 mb-6" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -15,7 +15,7 @@
|
||||
"label": "{TITLE}",
|
||||
"linkType": "static",
|
||||
"organizerInputPlaceholder": "https://p2p.mirotalk.com/join/80085ShinyPhone",
|
||||
"urlRegExp": "^(http|https):\\/\\/(p2p|sfu)\\.mirotalk\\.com\\/join\\/[a-zA-Z0-9]+$"
|
||||
"urlRegExp": "^(http|https):\\/\\/(p2p|sfu)\\.mirotalk\\.com\\/join\\/[a-zA-Z0-9._-]+$"
|
||||
}
|
||||
},
|
||||
"description": "Peer to peer real-time video conferences, optimized for small groups. Unlimited time, unlimited rooms each having 5-8 participants.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createContext, forwardRef, useContext, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -7,11 +7,10 @@ import { z } from "zod";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { RoutingFormEmbedButton, RoutingFormEmbedDialog } from "@calcom/features/embed/RoutingFormEmbed";
|
||||
import { EmbedDialogProvider } from "@calcom/features/embed/lib/hooks/useEmbedDialogCtx";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
@@ -38,33 +37,29 @@ import getFieldIdentifier from "../lib/getFieldIdentifier";
|
||||
import type { SerializableForm } from "../types/types";
|
||||
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
export type NewFormDialogState = { action: "new" | "duplicate"; target: string | null } | null;
|
||||
export type SetNewFormDialogState = React.Dispatch<React.SetStateAction<NewFormDialogState>>;
|
||||
const newFormModalQuerySchema = z.object({
|
||||
action: z.literal("new").or(z.literal("duplicate")),
|
||||
target: z.string().optional(),
|
||||
});
|
||||
|
||||
export const useOpenModal = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const openModal = (option: z.infer<typeof newFormModalQuerySchema>) => {
|
||||
const newQuery = new URLSearchParams(searchParams ?? undefined);
|
||||
newQuery.set("dialog", "new-form");
|
||||
Object.keys(option).forEach((key) => {
|
||||
newQuery.set(key, option[key as keyof typeof option] || "");
|
||||
});
|
||||
router.push(`${pathname}?${newQuery.toString()}`);
|
||||
};
|
||||
return openModal;
|
||||
};
|
||||
|
||||
function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
const routerQuery = useRouterQuery();
|
||||
function NewFormDialog({
|
||||
appUrl,
|
||||
newFormDialogState,
|
||||
setNewFormDialogState,
|
||||
}: {
|
||||
appUrl: string;
|
||||
newFormDialogState: NewFormDialogState;
|
||||
setNewFormDialogState: SetNewFormDialogState;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const action = newFormDialogState?.action;
|
||||
const target = newFormDialogState?.target;
|
||||
|
||||
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
router.push(`${appUrl}/form-edit/${variables.id}`);
|
||||
@@ -83,14 +78,12 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
shouldConnect: boolean;
|
||||
}>();
|
||||
|
||||
const { action, target } = routerQuery as z.infer<typeof newFormModalQuerySchema>;
|
||||
|
||||
const formToDuplicate = action === "duplicate" ? target : null;
|
||||
const teamId = action === "new" ? Number(target) : null;
|
||||
|
||||
const { register } = hookForm;
|
||||
return (
|
||||
<Dialog name="new-form" clearQueryParamsOnClose={["target", "action"]}>
|
||||
<Dialog open={newFormDialogState !== null} onOpenChange={(open) => !open && setNewFormDialogState(null)}>
|
||||
<DialogContent className="overflow-y-auto">
|
||||
<div className="mb-1">
|
||||
<h3
|
||||
@@ -187,15 +180,20 @@ function Dialogs({
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
deleteDialogFormId,
|
||||
newFormDialogState,
|
||||
setNewFormDialogState,
|
||||
}: {
|
||||
appUrl: string;
|
||||
deleteDialogOpen: boolean;
|
||||
setDeleteDialogOpen: (open: boolean) => void;
|
||||
deleteDialogFormId: string | null;
|
||||
newFormDialogState: NewFormDialogState | null;
|
||||
setNewFormDialogState: SetNewFormDialogState;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const deleteMutation = trpc.viewer.appRoutingForms.deleteForm.useMutation({
|
||||
onMutate: async ({ id: formId }) => {
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
@@ -253,13 +251,20 @@ function Dialogs({
|
||||
</ul>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<NewFormDialog appUrl={appUrl} />
|
||||
<NewFormDialog
|
||||
appUrl={appUrl}
|
||||
newFormDialogState={newFormDialogState}
|
||||
setNewFormDialogState={setNewFormDialogState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionsCtx = createContext({
|
||||
appUrl: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setNewFormDialogState: null as SetNewFormDialogState | null,
|
||||
newFormDialogState: null as NewFormDialogState,
|
||||
_delete: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
|
||||
onAction: (_arg: { routingForm: RoutingForm | null }) => {},
|
||||
@@ -272,12 +277,23 @@ const actionsCtx = createContext({
|
||||
},
|
||||
});
|
||||
|
||||
export function FormActionsProvider({ appUrl, children }: { appUrl: string; children: React.ReactNode }) {
|
||||
interface FormActionsProviderProps {
|
||||
appUrl: string;
|
||||
children: React.ReactNode;
|
||||
newFormDialogState: NewFormDialogState;
|
||||
setNewFormDialogState: SetNewFormDialogState;
|
||||
}
|
||||
|
||||
export function FormActionsProvider({
|
||||
appUrl,
|
||||
children,
|
||||
newFormDialogState,
|
||||
setNewFormDialogState,
|
||||
}: FormActionsProviderProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogFormId, setDeleteDialogFormId] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const toggleMutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
|
||||
onMutate: async ({ id: formId, disabled }) => {
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
@@ -320,40 +336,46 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
|
||||
|
||||
return (
|
||||
<>
|
||||
<actionsCtx.Provider
|
||||
value={{
|
||||
appUrl,
|
||||
_delete: {
|
||||
onAction: ({ routingForm }) => {
|
||||
if (!routingForm) {
|
||||
return;
|
||||
}
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogFormId(routingForm.id);
|
||||
<EmbedDialogProvider>
|
||||
<actionsCtx.Provider
|
||||
value={{
|
||||
appUrl,
|
||||
setNewFormDialogState,
|
||||
newFormDialogState,
|
||||
_delete: {
|
||||
onAction: ({ routingForm }) => {
|
||||
if (!routingForm) {
|
||||
return;
|
||||
}
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogFormId(routingForm.id);
|
||||
},
|
||||
isPending: false,
|
||||
},
|
||||
isPending: false,
|
||||
},
|
||||
toggle: {
|
||||
onAction: ({ routingForm, checked }) => {
|
||||
if (!routingForm) {
|
||||
return;
|
||||
}
|
||||
toggleMutation.mutate({
|
||||
...routingForm,
|
||||
disabled: !checked,
|
||||
});
|
||||
toggle: {
|
||||
onAction: ({ routingForm, checked }) => {
|
||||
if (!routingForm) {
|
||||
return;
|
||||
}
|
||||
toggleMutation.mutate({
|
||||
...routingForm,
|
||||
disabled: !checked,
|
||||
});
|
||||
},
|
||||
isPending: toggleMutation.isPending,
|
||||
},
|
||||
isPending: toggleMutation.isPending,
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
</actionsCtx.Provider>
|
||||
<Dialogs
|
||||
appUrl={appUrl}
|
||||
deleteDialogFormId={deleteDialogFormId}
|
||||
deleteDialogOpen={deleteDialogOpen}
|
||||
setDeleteDialogOpen={setDeleteDialogOpen}
|
||||
/>
|
||||
}}>
|
||||
{children}
|
||||
</actionsCtx.Provider>
|
||||
<Dialogs
|
||||
appUrl={appUrl}
|
||||
deleteDialogFormId={deleteDialogFormId}
|
||||
deleteDialogOpen={deleteDialogOpen}
|
||||
setDeleteDialogOpen={setDeleteDialogOpen}
|
||||
newFormDialogState={newFormDialogState}
|
||||
setNewFormDialogState={setNewFormDialogState}
|
||||
/>
|
||||
</EmbedDialogProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -398,7 +420,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
||||
extraClassNames,
|
||||
...additionalProps
|
||||
} = props;
|
||||
const { appUrl, _delete, toggle } = useContext(actionsCtx);
|
||||
const { appUrl, _delete, toggle, setNewFormDialogState } = useContext(actionsCtx);
|
||||
const dropdownCtxValue = useContext(dropdownCtx);
|
||||
const dropdown = dropdownCtxValue?.dropdown;
|
||||
const embedLink = `forms/${routingForm?.id}`;
|
||||
@@ -412,7 +434,6 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
const openModal = useOpenModal();
|
||||
const actionData: Record<
|
||||
FormActionType,
|
||||
ButtonProps & { as?: React.ElementType; render?: FormActionProps<unknown>["render"] }
|
||||
@@ -427,7 +448,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
||||
},
|
||||
},
|
||||
duplicate: {
|
||||
onClick: () => openModal({ action: "duplicate", target: routingForm?.id }),
|
||||
onClick: () => setNewFormDialogState?.({ action: "duplicate", target: routingForm?.id ?? null }),
|
||||
},
|
||||
embed: {
|
||||
as: RoutingFormEmbedButton,
|
||||
@@ -448,7 +469,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
||||
loading: _delete.isPending,
|
||||
},
|
||||
create: {
|
||||
onClick: () => openModal({ action: "new", target: "" }),
|
||||
onClick: () => setNewFormDialogState?.({ action: "new", target: "" }),
|
||||
},
|
||||
copyRedirectUrl: {
|
||||
onClick: () => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { RoutingPages } from "../lib/RoutingPages";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { findMatchingRoute } from "../lib/processRoute";
|
||||
import type { FormResponse, NonRouterRoute, SerializableForm } from "../types/types";
|
||||
import type { NewFormDialogState } from "./FormActions";
|
||||
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
|
||||
import FormInputFields from "./FormInputFields";
|
||||
import { InfoLostWarningDialog } from "./InfoLostWarningDialog";
|
||||
@@ -680,7 +681,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
const { data: user } = useMeQuery();
|
||||
|
||||
const [newFormDialogState, setNewFormDialogState] = useState<NewFormDialogState>(null);
|
||||
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
|
||||
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
|
||||
const [showInfoLostDialog, setShowInfoLostDialog] = useState(false);
|
||||
@@ -752,7 +753,10 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
|
||||
...data,
|
||||
});
|
||||
}}>
|
||||
<FormActionsProvider appUrl={appUrl}>
|
||||
<FormActionsProvider
|
||||
appUrl={appUrl}
|
||||
newFormDialogState={newFormDialogState}
|
||||
setNewFormDialogState={setNewFormDialogState}>
|
||||
<Meta title={form.name} description={form.description || ""} />
|
||||
<ShellMain
|
||||
heading={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Routing Forms",
|
||||
"title": "Routing Forms",
|
||||
"name": "Routing",
|
||||
"title": "Routing",
|
||||
"isGlobal": true,
|
||||
"slug": "routing-forms",
|
||||
"type": "routing-forms_other",
|
||||
@@ -10,11 +10,11 @@
|
||||
"variant": "other",
|
||||
"categories": ["automation"],
|
||||
"publisher": "Cal.com, Inc.",
|
||||
"simplePath": "/apps/routing-forms",
|
||||
"simplePath": "/routing",
|
||||
"email": "help@cal.com",
|
||||
"licenseRequired": true,
|
||||
"teamsPlanRequired": {
|
||||
"upgradeUrl": "/routing-forms/forms"
|
||||
"upgradeUrl": "/routing"
|
||||
},
|
||||
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user",
|
||||
"__createdUsingCli": true,
|
||||
|
||||
@@ -15,7 +15,7 @@ import isRouter from "../lib/isRouter";
|
||||
import { onFormSubmission } from "../trpc/utils";
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
|
||||
type Form = SerializableForm<
|
||||
export type Form = SerializableForm<
|
||||
App_RoutingForms_Form & {
|
||||
user: {
|
||||
id: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user