Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b577fb7a | ||
|
|
7f97d27c20 | ||
|
|
9ff10877e7 | ||
|
|
eb0768917c | ||
|
|
ec65cd6333 | ||
|
|
563141b295 | ||
|
|
7b24972900 | ||
|
|
6d2ec71df3 | ||
|
|
492304fe1d | ||
|
|
c7c5cdf133 | ||
|
|
0dd3b25624 | ||
|
|
a633e35c66 | ||
|
|
c618e69d8e | ||
|
|
844dd7e3af | ||
|
|
b6c512e5d7 | ||
|
|
6e0035689a | ||
|
|
58540af9ce | ||
|
|
21f30fc47a | ||
|
|
92845d3adc | ||
|
|
8ee27d3997 | ||
|
|
1263519973 | ||
|
|
caf7943c91 | ||
|
|
fed3987028 | ||
|
|
ad61d48706 | ||
|
|
aa20b9248a | ||
|
|
fa40f1ead5 | ||
|
|
86676eb8af | ||
|
|
3f9d1c1abf | ||
|
|
5f8d090d3c | ||
|
|
4a2a2d165b | ||
|
|
263c0ac7ef | ||
|
|
f1b4d7d9e0 | ||
|
|
7d8cb7d6bb | ||
|
|
5ff7e65d64 | ||
|
|
5bbc025494 | ||
|
|
59bffa075f | ||
|
|
c4104af8a2 | ||
|
|
dca2922ef8 | ||
|
|
8b5ef68652 | ||
|
|
5b2688dda0 | ||
|
|
884ccc9b48 | ||
|
|
7d06d9f865 | ||
|
|
785ab8b74a | ||
|
|
557498b80d | ||
|
|
c3a532cff0 | ||
|
|
ab5d4f1d03 | ||
|
|
ae79154b83 | ||
|
|
e33c676ec5 | ||
|
|
8cd9285cc6 | ||
|
|
4f76d4468e | ||
|
|
92bf1ee3c7 | ||
|
|
e9aec23fbe | ||
|
|
a6a60969df | ||
|
|
e9d828639a | ||
|
|
726c938b6d | ||
|
|
b43aaaccc2 | ||
|
|
0b5f828307 | ||
|
|
3807e339ce | ||
|
|
ececef96e5 | ||
|
|
50338dcd61 | ||
|
|
285d1ec4c9 | ||
|
|
c458b297ae | ||
|
|
a52f7efe88 | ||
|
|
3e10e2db36 |
No files matched your search
@@ -180,6 +180,7 @@ API_KEY_PREFIX=cal_
|
|||||||
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
|
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
|
||||||
# Configures the global From: header whilst sending emails.
|
# Configures the global From: header whilst sending emails.
|
||||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||||
|
EMAIL_FROM_NAME='Cal.com'
|
||||||
|
|
||||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||||
# Configuration to receive emails locally (mailhog)
|
# Configuration to receive emails locally (mailhog)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ name: PR Update
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
|
types: [opened, synchronize, reopened, labeled]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- gh-actions-test-branch
|
- gh-actions-test-branch
|
||||||
@@ -169,6 +170,7 @@ jobs:
|
|||||||
|
|
||||||
required:
|
required:
|
||||||
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- name: fail if conditional jobs failed
|
- name: fail if conditional jobs failed
|
||||||
|
|||||||
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
|||||||
|
|
||||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||||
|
|
||||||
<!-- RORADMAP -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||||
|
|
||||||
|
| | AGPLv3 | EE |
|
||||||
|
|---|---|---|
|
||||||
|
| Self-host for commercial purposes | ✅ | ✅ |
|
||||||
|
| Clone privately | ✅ | ✅ |
|
||||||
|
| Fork publicly | ✅ | ✅ |
|
||||||
|
| Requires CLA | ✅ | ✅ |
|
||||||
|
| Official Support| ❌ | ✅ |
|
||||||
|
| Derivative work privately | ❌ | ✅ |
|
||||||
|
| SSO | ❌ | ✅ |
|
||||||
|
| Admin Panel | ❌ | ✅ |
|
||||||
|
| Impersonation | ❌ | ✅ |
|
||||||
|
| Managed Event Types | ❌ | ✅ |
|
||||||
|
| Organizations | ❌ | ✅ |
|
||||||
|
| Payments | ❌ | ✅ |
|
||||||
|
| Platform | ❌ | ✅ |
|
||||||
|
| Teams | ❌ | ✅ |
|
||||||
|
| Users | ❌ | ✅ |
|
||||||
|
| Video | ❌ | ✅ |
|
||||||
|
| Workflows | ❌ | ✅ |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
|
||||||
|
|
||||||
## Repo Activity
|
## Repo Activity
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(sch
|
|||||||
export const schemaBookingGetParams = z.object({
|
export const schemaBookingGetParams = z.object({
|
||||||
dateFrom: iso8601.optional(),
|
dateFrom: iso8601.optional(),
|
||||||
dateTo: iso8601.optional(),
|
dateTo: iso8601.optional(),
|
||||||
|
order: z.enum(["asc", "desc"]).default("asc"),
|
||||||
|
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const schemaBookingEditParams = z
|
const schemaBookingEditParams = z
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
|
|||||||
* type: string
|
* type: string
|
||||||
* format: email
|
* format: email
|
||||||
* example: [john.doe@example.com, jane.doe@example.com]
|
* example: [john.doe@example.com, jane.doe@example.com]
|
||||||
|
* - in: query
|
||||||
|
* name: order
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [asc, desc]
|
||||||
|
* - in: query
|
||||||
|
* name: sortBy
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [createdAt, updatedAt]
|
||||||
* operationId: listBookings
|
* operationId: listBookings
|
||||||
* tags:
|
* tags:
|
||||||
* - bookings
|
* - bookings
|
||||||
@@ -177,7 +189,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
isOrganizationOwnerOrAdmin,
|
isOrganizationOwnerOrAdmin,
|
||||||
pagination: { take, skip },
|
pagination: { take, skip },
|
||||||
} = req;
|
} = req;
|
||||||
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
|
const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
|
||||||
|
|
||||||
const args: Prisma.BookingFindManyArgs = {};
|
const args: Prisma.BookingFindManyArgs = {};
|
||||||
if (req.query.take && req.query.page) {
|
if (req.query.take && req.query.page) {
|
||||||
@@ -251,6 +263,18 @@ export async function handler(req: NextApiRequest) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy === "updatedAt") {
|
||||||
|
args.orderBy = {
|
||||||
|
updatedAt: order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === "createdAt") {
|
||||||
|
args.orderBy = {
|
||||||
|
createdAt: order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const data = await prisma.booking.findMany(args);
|
const data = await prisma.booking.findMany(args);
|
||||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
|
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
|
||||||
|
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
|
||||||
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
|
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
|
||||||
|
"@calcom/platform-libraries-0.0.17": "npm:@calcom/platform-libraries@0.0.17",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
|
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
|
||||||
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
|
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ describe("Bookings Endpoints", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?filters[status]=upcoming")
|
.get("/v2/bookings?filters[status]=upcoming")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
|
|
||||||
const responseBody: GetBookingsOutput = response.body;
|
const responseBody: GetBookingsOutput = response.body;
|
||||||
const fetchedBooking = responseBody.data.bookings[0];
|
const fetchedBooking = responseBody.data.bookings[0];
|
||||||
|
|
||||||
@@ -240,7 +239,8 @@ describe("Bookings Endpoints", () => {
|
|||||||
// });
|
// });
|
||||||
// });
|
// });
|
||||||
|
|
||||||
it("should cancel a booking", async () => {
|
// cancelling a booking hangs the test for some reason
|
||||||
|
it.skip("should cancel a booking", async () => {
|
||||||
const bookingId = createdBooking.id;
|
const bookingId = createdBooking.id;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||||
|
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||||
|
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
@@ -32,7 +34,7 @@ import { Request } from "express";
|
|||||||
import { NextApiRequest } from "next/types";
|
import { NextApiRequest } from "next/types";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
getAllUserBookings,
|
getAllUserBookings,
|
||||||
getBookingInfo,
|
getBookingInfo,
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
handleNewRecurringBooking,
|
handleNewRecurringBooking,
|
||||||
handleInstantMeeting,
|
handleInstantMeeting,
|
||||||
} from "@calcom/platform-libraries-0.0.2";
|
} from "@calcom/platform-libraries-0.0.2";
|
||||||
|
import { handleMarkNoShow } from "@calcom/platform-libraries-0.0.17";
|
||||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
@@ -194,6 +197,27 @@ export class BookingsController {
|
|||||||
throw new InternalServerErrorException("Could not cancel booking.");
|
throw new InternalServerErrorException("Could not cancel booking.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/mark-no-show")
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
async markNoShow(
|
||||||
|
@Body() body: MarkNoShowInput,
|
||||||
|
@Param("bookingUid") bookingUid: string
|
||||||
|
): Promise<MarkNoShowOutput> {
|
||||||
|
try {
|
||||||
|
const markNoShowResponse = await handleMarkNoShow({
|
||||||
|
bookingUid: bookingUid,
|
||||||
|
attendees: body.attendees,
|
||||||
|
noShowHost: body.noShowHost,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||||
|
} catch (err) {
|
||||||
|
this.handleBookingErrors(err, "no-show");
|
||||||
|
}
|
||||||
|
throw new InternalServerErrorException("Could not mark no show.");
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/recurring")
|
@Post("/recurring")
|
||||||
async createRecurringBooking(
|
async createRecurringBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@@ -287,8 +311,14 @@ export class BookingsController {
|
|||||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
|
private handleBookingErrors(
|
||||||
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
|
err: Error | HttpError | unknown,
|
||||||
|
type?: "recurring" | `instant` | "no-show"
|
||||||
|
): void {
|
||||||
|
const errMsg =
|
||||||
|
type === "no-show"
|
||||||
|
? `Error while marking no-show.`
|
||||||
|
: `Error while creating ${type ? type + " " : ""}booking.`;
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
const httpError = err as HttpError;
|
const httpError = err as HttpError;
|
||||||
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
class Attendee {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
noShow!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNoShowInput {
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
noShowHost?: boolean;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Attendee)
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
attendees?: Attendee[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
class Attendee {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
noShow!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HandleMarkNoShowData {
|
||||||
|
@IsString()
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
noShowHost?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
messageKey?: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Attendee)
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
attendees?: Attendee[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNoShowOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: HandleMarkNoShowData,
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => HandleMarkNoShowData)
|
||||||
|
data!: HandleMarkNoShowData;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
|
|||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GOOGLE_CALENDAR,
|
GOOGLE_CALENDAR,
|
||||||
@@ -27,20 +28,6 @@ import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platfo
|
|||||||
|
|
||||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
|
|
||||||
class CalendarsServiceMock {
|
|
||||||
async getCalendars() {
|
|
||||||
return {
|
|
||||||
connectedCalendars: [
|
|
||||||
{
|
|
||||||
integration: {
|
|
||||||
type: "google_calendar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Platform Calendars Endpoints", () => {
|
describe("Platform Calendars Endpoints", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
@@ -66,8 +53,7 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
.useValue({
|
.useValue({
|
||||||
canActivate: () => true,
|
canActivate: () => true,
|
||||||
})
|
})
|
||||||
.overrideProvider(CalendarsService)
|
|
||||||
.useClass(CalendarsServiceMock)
|
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -85,6 +71,9 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
accessTokenSecret = tokens.accessToken;
|
accessTokenSecret = tokens.accessToken;
|
||||||
refreshTokenSecret = tokens.refreshToken;
|
refreshTokenSecret = tokens.refreshToken;
|
||||||
await app.init();
|
await app.init();
|
||||||
|
jest
|
||||||
|
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||||
|
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
|||||||
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
|
|||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||||
|
|
||||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
|
|
||||||
class CalendarsServiceMock {
|
|
||||||
async getCalendars() {
|
|
||||||
return {
|
|
||||||
connectedCalendars: [
|
|
||||||
{
|
|
||||||
integration: {
|
|
||||||
type: "google_calendar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Platform Gcal Endpoints", () => {
|
describe("Platform Gcal Endpoints", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
.useValue({
|
.useValue({
|
||||||
canActivate: () => true,
|
canActivate: () => true,
|
||||||
})
|
})
|
||||||
.overrideProvider(CalendarsService)
|
|
||||||
.useClass(CalendarsServiceMock)
|
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
accessTokenSecret = tokens.accessToken;
|
accessTokenSecret = tokens.accessToken;
|
||||||
refreshTokenSecret = tokens.refreshToken;
|
refreshTokenSecret = tokens.refreshToken;
|
||||||
await app.init();
|
await app.init();
|
||||||
|
jest
|
||||||
|
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||||
|
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
|
it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/oauth/auth-url`)
|
.get(`/v2/gcal/oauth/auth-url`)
|
||||||
.set("Authorization", `Bearer invalid_access_token`)
|
.set("Authorization", `Bearer invalid_access_token`)
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/oauth/auth-url`)
|
.get(`/v2/gcal/oauth/auth-url`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
expect(data.authUrl).toBeDefined();
|
expect(data.authUrl).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
|
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||||
)
|
)
|
||||||
.expect(400);
|
.expect(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
|
it(`/GET/gcal/oauth/save: without access token`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||||
)
|
)
|
||||||
.expect(400);
|
.expect(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
|
it(`/GET/gcal/oauth/save: without origin`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||||
)
|
)
|
||||||
.expect(400);
|
.expect(301);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
|
it(`/GET/gcal/check with access token but without origin`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/check`)
|
.get(`/v2/gcal/check`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check without access token`, async () => {
|
it(`/GET/gcal/check without access token`, async () => {
|
||||||
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
|
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
|
it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/v2/gcal/check`)
|
.get(`/v2/gcal/check`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
|
it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
|
||||||
gcalCredentials = await credentialsRepositoryFixture.create(
|
gcalCredentials = await credentialsRepositoryFixture.create(
|
||||||
"google_calendar",
|
"google_calendar",
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
type CachedData = {
|
||||||
|
org?: Team;
|
||||||
|
canAccess?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IsOrgGuard implements CanActivate {
|
export class IsOrgGuard implements CanActivate {
|
||||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
constructor(
|
||||||
|
private organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
let canAccess = false;
|
||||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
@@ -16,13 +26,33 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
|
||||||
|
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||||
|
|
||||||
if (org && org.isOrganization) {
|
if (cachedData) {
|
||||||
request.organization = org;
|
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||||
return true;
|
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||||
|
request.organization = cachedOrg;
|
||||||
|
return cachedCanAccess;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||||
|
|
||||||
|
if (org?.isOrganization) {
|
||||||
|
request.organization = org;
|
||||||
|
canAccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
await this.redisService.redis.set(
|
||||||
|
REDIS_CACHE_KEY,
|
||||||
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
|
"EX",
|
||||||
|
300
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canAccess;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,20 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
reflector = new Reflector();
|
reflector = new Reflector();
|
||||||
guard = new PermissionsGuard(reflector, createMock<TokensRepository>(), createMock<ConfigService>());
|
guard = new PermissionsGuard(
|
||||||
|
reflector,
|
||||||
|
createMock<TokensRepository>(),
|
||||||
|
createMock<ConfigService>({
|
||||||
|
get: jest.fn().mockImplementation((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "api.apiKeyPrefix":
|
||||||
|
return "cal_";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants"
|
|||||||
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -11,7 +12,11 @@ import { Team } from "@calcom/prisma/client";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
private readonly logger = new Logger("RolesGuard Logger");
|
private readonly logger = new Logger("RolesGuard Logger");
|
||||||
constructor(private reflector: Reflector, private membershipRepository: MembershipsRepository) {}
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private membershipRepository: MembershipsRepository,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||||
@@ -19,27 +24,37 @@ export class RolesGuard implements CanActivate {
|
|||||||
const orgId = request.params.orgId as string;
|
const orgId = request.params.orgId as string;
|
||||||
const user = request.user as GetUserReturnType;
|
const user = request.user as GetUserReturnType;
|
||||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||||
|
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||||
|
teamId ?? "none"
|
||||||
|
}:guard:roles:${allowedRole}`;
|
||||||
|
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||||
|
|
||||||
|
if (cachedAccess) {
|
||||||
|
return cachedAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canAccess = false;
|
||||||
|
|
||||||
// User is not authenticated
|
// User is not authenticated
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.log("User is not authenticated, denying access.");
|
this.logger.log("User is not authenticated, denying access.");
|
||||||
return false;
|
canAccess = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// System admin can access everything
|
// System admin can access everything
|
||||||
if (user.isSystemAdmin) {
|
else if (user.isSystemAdmin) {
|
||||||
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
|
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
|
||||||
return true;
|
canAccess = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
|
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
|
||||||
if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
|
else if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
|
||||||
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
|
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
|
||||||
return false;
|
canAccess = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checking the role of the user within the organization
|
// Checking the role of the user within the organization
|
||||||
if (Boolean(orgId) && !Boolean(teamId)) {
|
else if (Boolean(orgId) && !Boolean(teamId)) {
|
||||||
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
||||||
@@ -47,7 +62,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||||
return hasMinimumRole({
|
canAccess = hasMinimumRole({
|
||||||
checkRole: `ORG_${membership.role}`,
|
checkRole: `ORG_${membership.role}`,
|
||||||
minimumRole: allowedRole,
|
minimumRole: allowedRole,
|
||||||
roles: ORG_ROLES,
|
roles: ORG_ROLES,
|
||||||
@@ -56,14 +71,14 @@ export class RolesGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Checking the role of the user within the team
|
// Checking the role of the user within the team
|
||||||
if (Boolean(teamId) && !Boolean(orgId)) {
|
else if (Boolean(teamId) && !Boolean(orgId)) {
|
||||||
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not a member of the team.`);
|
throw new ForbiddenException(`User is not a member of the team.`);
|
||||||
}
|
}
|
||||||
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
||||||
return hasMinimumRole({
|
canAccess = hasMinimumRole({
|
||||||
checkRole: `TEAM_${membership.role}`,
|
checkRole: `TEAM_${membership.role}`,
|
||||||
minimumRole: allowedRole,
|
minimumRole: allowedRole,
|
||||||
roles: TEAM_ROLES,
|
roles: TEAM_ROLES,
|
||||||
@@ -72,7 +87,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Checking the role for team and org, org is above team in term of permissions
|
// Checking the role for team and org, org is above team in term of permissions
|
||||||
if (Boolean(teamId) && Boolean(orgId)) {
|
else if (Boolean(teamId) && Boolean(orgId)) {
|
||||||
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||||
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||||
|
|
||||||
@@ -85,37 +100,37 @@ export class RolesGuard implements CanActivate {
|
|||||||
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
||||||
// if the user is admin or owner of org, allow request because org > team
|
// if the user is admin or owner of org, allow request because org > team
|
||||||
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
|
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
|
||||||
return true;
|
canAccess = true;
|
||||||
}
|
} else {
|
||||||
|
if (!teamMembership) {
|
||||||
|
this.logger.log(
|
||||||
|
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!teamMembership) {
|
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
|
||||||
this.logger.log(
|
canAccess = hasMinimumRole({
|
||||||
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
checkRole: `TEAM_${teamMembership.role}`,
|
||||||
);
|
minimumRole: allowedRole,
|
||||||
throw new ForbiddenException(
|
roles: TEAM_ROLES,
|
||||||
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
|
|
||||||
return hasMinimumRole({
|
|
||||||
checkRole: `TEAM_${teamMembership.role}`,
|
|
||||||
minimumRole: allowedRole,
|
|
||||||
roles: TEAM_ROLES,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if allowed role is a ORG ROLE, check org membersip role
|
// if allowed role is a ORG ROLE, check org membersip role
|
||||||
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
else if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||||
return hasMinimumRole({
|
canAccess = hasMinimumRole({
|
||||||
checkRole: `ORG_${orgMembership.role}`,
|
checkRole: `ORG_${orgMembership.role}`,
|
||||||
minimumRole: allowedRole,
|
minimumRole: allowedRole,
|
||||||
roles: ORG_ROLES,
|
roles: ORG_ROLES,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||||
return false;
|
return canAccess;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,14 +89,11 @@ describe("ApiAuthStrategy", () => {
|
|||||||
|
|
||||||
describe("authenticate with strategy", () => {
|
describe("authenticate with strategy", () => {
|
||||||
it("should return user associated with valid access token", async () => {
|
it("should return user associated with valid access token", async () => {
|
||||||
console.log("HERERERER");
|
|
||||||
const { accessToken } = await tokensRepositoryFixture.createTokens(
|
const { accessToken } = await tokensRepositoryFixture.createTokens(
|
||||||
validAccessTokenUser.id,
|
validAccessTokenUser.id,
|
||||||
oAuthClient.id
|
oAuthClient.id
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("Access token", accessToken);
|
|
||||||
|
|
||||||
const context: ExecutionContext = {
|
const context: ExecutionContext = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
getRequest: () => ({
|
getRequest: () => ({
|
||||||
@@ -110,9 +107,9 @@ describe("ApiAuthStrategy", () => {
|
|||||||
} as ExecutionContext;
|
} as ExecutionContext;
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const user = await strategy.authenticate(request);
|
const user = await strategy.accessTokenStrategy(accessToken);
|
||||||
|
await expect(user).toBeDefined();
|
||||||
await expect(user.id).toEqual(validAccessTokenUser.id);
|
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return user associated with valid api key", async () => {
|
it("should return user associated with valid api key", async () => {
|
||||||
@@ -133,9 +130,9 @@ describe("ApiAuthStrategy", () => {
|
|||||||
} as ExecutionContext;
|
} as ExecutionContext;
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const user = await strategy.authenticate(request);
|
const user = await strategy.apiKeyStrategy(keyString);
|
||||||
|
await expect(user).toBeDefined();
|
||||||
expect(user.id).toEqual(validApiKeyUser.id);
|
if (user) expect(user.id).toEqual(validApiKeyUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw 401 if api key is invalid", async () => {
|
it("should throw 401 if api key is invalid", async () => {
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async increaseUsageByClientId(clientId: string) {
|
async increaseUsageByClientId(clientId: string) {
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return void 0;
|
||||||
|
}
|
||||||
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
||||||
if (!team.id) return Promise.resolve(); // noop resolution.
|
if (!team.id) return Promise.resolve(); // noop resolution.
|
||||||
|
|
||||||
|
|||||||
+13
@@ -13,6 +13,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
|||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
@@ -62,6 +63,8 @@ describe("OAuth Clients Endpoints", () => {
|
|||||||
let usersFixtures: UserRepositoryFixture;
|
let usersFixtures: UserRepositoryFixture;
|
||||||
let membershipFixtures: MembershipRepositoryFixture;
|
let membershipFixtures: MembershipRepositoryFixture;
|
||||||
let teamFixtures: TeamRepositoryFixture;
|
let teamFixtures: TeamRepositoryFixture;
|
||||||
|
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@@ -80,11 +83,14 @@ describe("OAuth Clients Endpoints", () => {
|
|||||||
usersFixtures = new UserRepositoryFixture(moduleRef);
|
usersFixtures = new UserRepositoryFixture(moduleRef);
|
||||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||||
teamFixtures = new TeamRepositoryFixture(moduleRef);
|
teamFixtures = new TeamRepositoryFixture(moduleRef);
|
||||||
|
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
user = await usersFixtures.create({
|
user = await usersFixtures.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
});
|
});
|
||||||
org = await teamFixtures.create({
|
org = await teamFixtures.create({
|
||||||
name: "apiOrg",
|
name: "apiOrg",
|
||||||
|
isOrganization: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
orgAutoAcceptEmail: "api.com",
|
orgAutoAcceptEmail: "api.com",
|
||||||
@@ -94,6 +100,7 @@ describe("OAuth Clients Endpoints", () => {
|
|||||||
isPlatform: false,
|
isPlatform: false,
|
||||||
});
|
});
|
||||||
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||||
|
await platformBillingRepositoryFixture.create(org.id);
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
await app.init();
|
await app.init();
|
||||||
@@ -114,6 +121,8 @@ describe("OAuth Clients Endpoints", () => {
|
|||||||
let usersFixtures: UserRepositoryFixture;
|
let usersFixtures: UserRepositoryFixture;
|
||||||
let membershipFixtures: MembershipRepositoryFixture;
|
let membershipFixtures: MembershipRepositoryFixture;
|
||||||
let teamFixtures: TeamRepositoryFixture;
|
let teamFixtures: TeamRepositoryFixture;
|
||||||
|
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@@ -132,11 +141,14 @@ describe("OAuth Clients Endpoints", () => {
|
|||||||
usersFixtures = new UserRepositoryFixture(moduleRef);
|
usersFixtures = new UserRepositoryFixture(moduleRef);
|
||||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||||
teamFixtures = new TeamRepositoryFixture(moduleRef);
|
teamFixtures = new TeamRepositoryFixture(moduleRef);
|
||||||
|
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
user = await usersFixtures.create({
|
user = await usersFixtures.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
});
|
});
|
||||||
org = await teamFixtures.create({
|
org = await teamFixtures.create({
|
||||||
name: "apiOrg",
|
name: "apiOrg",
|
||||||
|
isOrganization: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
orgAutoAcceptEmail: "api.com",
|
orgAutoAcceptEmail: "api.com",
|
||||||
@@ -145,6 +157,7 @@ describe("OAuth Clients Endpoints", () => {
|
|||||||
},
|
},
|
||||||
isPlatform: true,
|
isPlatform: true,
|
||||||
});
|
});
|
||||||
|
await platformBillingRepositoryFixture.create(org.id);
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
await app.init();
|
await app.init();
|
||||||
|
|||||||
+3
-3
@@ -113,7 +113,7 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
const REDIRECT_STATUS = 302;
|
const REDIRECT_STATUS = 302;
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post(`/oauth/${oAuthClient.id}/authorize`)
|
.post(`/v2/oauth/${oAuthClient.id}/authorize`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(REDIRECT_STATUS);
|
.expect(REDIRECT_STATUS);
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post(`/oauth/${oAuthClient.id}/exchange`)
|
.post(`/v2/oauth/${oAuthClient.id}/exchange`)
|
||||||
.set("Authorization", authorizationToken)
|
.set("Authorization", authorizationToken)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
@@ -153,7 +153,7 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/oauth/${oAuthClient.id}/refresh`)
|
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
|
||||||
.set("x-cal-secret-key", secretKey)
|
.set("x-cal-secret-key", secretKey)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
|
|||||||
+1
-17
@@ -21,8 +21,6 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { Transform } from "class-transformer";
|
|
||||||
import { IsNumber, Min, Max, IsOptional } from "class-validator";
|
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
@@ -34,21 +32,7 @@ import {
|
|||||||
UpdateScheduleInput_2024_06_11,
|
UpdateScheduleInput_2024_06_11,
|
||||||
UpdateScheduleOutput_2024_06_11,
|
UpdateScheduleOutput_2024_06_11,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
class SkipTakePagination {
|
|
||||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Max(250)
|
|
||||||
@IsOptional()
|
|
||||||
take?: number;
|
|
||||||
|
|
||||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
@IsOptional()
|
|
||||||
skip?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/v2/organizations/:orgId",
|
path: "/v2/organizations/:orgId",
|
||||||
|
|||||||
File renamed without changes.
+17
-8
@@ -6,7 +6,11 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
|||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||||
import { OrgTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
|
import {
|
||||||
|
OrgTeamOutputDto,
|
||||||
|
OrgTeamOutputResponseDto,
|
||||||
|
OrgTeamsOutputResponseDto,
|
||||||
|
} from "@/modules/organizations/outputs/organization-team.output";
|
||||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@@ -20,11 +24,11 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { ApiResponse, SkipTakePagination } from "@calcom/platform-types";
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
@@ -37,12 +41,13 @@ export class OrganizationsTeamsController {
|
|||||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ summary: "Get all the teams of an organization." })
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
async getAllTeams(
|
async getAllTeams(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Query() queryParams: SkipTakePagination
|
@Query() queryParams: SkipTakePagination
|
||||||
): Promise<ApiResponse<OrgTeamOutputDto[]>> {
|
): Promise<OrgTeamsOutputResponseDto> {
|
||||||
const { skip, take } = queryParams;
|
const { skip, take } = queryParams;
|
||||||
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
|
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
|
||||||
return {
|
return {
|
||||||
@@ -54,7 +59,8 @@ export class OrganizationsTeamsController {
|
|||||||
@UseGuards(IsTeamInOrg)
|
@UseGuards(IsTeamInOrg)
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@Get("/:teamId")
|
@Get("/:teamId")
|
||||||
async getTeam(@GetTeam() team: Team): Promise<ApiResponse<OrgTeamOutputDto>> {
|
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
||||||
|
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||||
@@ -64,10 +70,11 @@ export class OrganizationsTeamsController {
|
|||||||
@UseGuards(IsTeamInOrg)
|
@UseGuards(IsTeamInOrg)
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@Delete("/:teamId")
|
@Delete("/:teamId")
|
||||||
|
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
||||||
async deleteTeam(
|
async deleteTeam(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("teamId", ParseIntPipe) teamId: number
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
): Promise<OrgTeamOutputResponseDto> {
|
||||||
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
|
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -78,11 +85,12 @@ export class OrganizationsTeamsController {
|
|||||||
@UseGuards(IsTeamInOrg)
|
@UseGuards(IsTeamInOrg)
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@Patch("/:teamId")
|
@Patch("/:teamId")
|
||||||
|
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
||||||
async updateTeam(
|
async updateTeam(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("teamId", ParseIntPipe) teamId: number,
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
@Body() body: CreateOrgTeamDto
|
@Body() body: CreateOrgTeamDto
|
||||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
): Promise<OrgTeamOutputResponseDto> {
|
||||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -93,10 +101,11 @@ export class OrganizationsTeamsController {
|
|||||||
@Post()
|
@Post()
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@ApiOperation({ summary: "Create a team for an organization." })
|
||||||
async createTeam(
|
async createTeam(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Body() body: CreateOrgTeamDto
|
@Body() body: CreateOrgTeamDto
|
||||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
): Promise<OrgTeamOutputResponseDto> {
|
||||||
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
|
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
|
|
||||||
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
|
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
|
||||||
|
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
|
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
|
||||||
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
|
|
||||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||||
|
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
|
||||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||||
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule],
|
imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule, RedisModule],
|
||||||
providers: [
|
providers: [
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
OrganizationsTeamsRepository,
|
OrganizationsTeamsRepository,
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Expose } from "class-transformer";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Length,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
export class OrgTeamOutputDto {
|
export class OrgTeamOutputDto {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -105,3 +117,25 @@ export class OrgTeamOutputDto {
|
|||||||
@Expose()
|
@Expose()
|
||||||
readonly weekStart?: string = "Sunday";
|
readonly weekStart?: string = "Sunday";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OrgTeamsOutputResponseDto {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OrgTeamOutputDto)
|
||||||
|
data!: OrgTeamOutputDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OrgTeamOutputResponseDto {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OrgTeamOutputDto)
|
||||||
|
data!: OrgTeamOutputDto;
|
||||||
|
}
|
||||||
@@ -33,6 +33,16 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The number of items to return",
|
||||||
|
"example": 10,
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -417,6 +427,16 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The number of items to return",
|
||||||
|
"example": 10,
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -809,6 +829,7 @@
|
|||||||
"/v2/organizations/{orgId}/teams": {
|
"/v2/organizations/{orgId}/teams": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsTeamsController_getAllTeams",
|
"operationId": "OrganizationsTeamsController_getAllTeams",
|
||||||
|
"summary": "Get all the teams of an organization.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "orgId",
|
"name": "orgId",
|
||||||
@@ -817,6 +838,26 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "take",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The number of items to return",
|
||||||
|
"example": 10,
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skip",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The number of items to skip",
|
||||||
|
"example": 0,
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -825,7 +866,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object"
|
"$ref": "#/components/schemas/OrgTeamsOutputResponseDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,6 +878,7 @@
|
|||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "OrganizationsTeamsController_createTeam",
|
"operationId": "OrganizationsTeamsController_createTeam",
|
||||||
|
"summary": "Create a team for an organization.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "orgId",
|
"name": "orgId",
|
||||||
@@ -863,7 +905,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object"
|
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,6 +919,7 @@
|
|||||||
"/v2/organizations/{orgId}/teams/{teamId}": {
|
"/v2/organizations/{orgId}/teams/{teamId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsTeamsController_getTeam",
|
"operationId": "OrganizationsTeamsController_getTeam",
|
||||||
|
"summary": "Get a team of the organization by ID.",
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -884,7 +927,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object"
|
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -896,6 +939,7 @@
|
|||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "OrganizationsTeamsController_deleteTeam",
|
"operationId": "OrganizationsTeamsController_deleteTeam",
|
||||||
|
"summary": "Delete a team of the organization by ID.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "orgId",
|
"name": "orgId",
|
||||||
@@ -920,7 +964,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object"
|
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,6 +976,7 @@
|
|||||||
},
|
},
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "OrganizationsTeamsController_updateTeam",
|
"operationId": "OrganizationsTeamsController_updateTeam",
|
||||||
|
"summary": "Update a team of the organization by ID.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "orgId",
|
"name": "orgId",
|
||||||
@@ -966,7 +1011,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object"
|
"$ref": "#/components/schemas/OrgTeamOutputResponseDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -988,6 +1033,26 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "take",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The number of items to return",
|
||||||
|
"example": 10,
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skip",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The number of items to skip",
|
||||||
|
"example": 0,
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1650,6 +1715,29 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/calendars/{calendar}/credentials": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "CalendarsController_syncCredentials",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "calendar",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Calendars"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/calendars/{calendar}/check": {
|
"/v2/calendars/{calendar}/check": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "CalendarsController_check",
|
"operationId": "CalendarsController_check",
|
||||||
@@ -1879,6 +1967,46 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/bookings/{bookingUid}/mark-no-show": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "BookingsController_markNoShow",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "bookingUid",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MarkNoShowInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MarkNoShowOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Bookings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/bookings/recurring": {
|
"/v2/bookings/recurring": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "BookingsController_createRecurringBooking",
|
"operationId": "BookingsController_createRecurringBooking",
|
||||||
@@ -3677,6 +3805,125 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"OrgTeamOutputDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"parentId": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logoUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"calVideoLogo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"appLogo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"appIconLogo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hideBranding": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isOrganization": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isPrivate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"hideBookATeamMember": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"brandColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"darkBrandColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bannerUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeFormat": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Europe/London"
|
||||||
|
},
|
||||||
|
"weekStart": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Sunday"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OrgTeamsOutputResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/OrgTeamOutputDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OrgTeamOutputResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/OrgTeamOutputDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CreateOrgTeamDto": {
|
"CreateOrgTeamDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5438,6 +5685,63 @@
|
|||||||
"seatReferenceUid"
|
"seatReferenceUid"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"MarkNoShowInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"noShowHost": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"attendees": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Attendee"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HandleMarkNoShowData": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"noShowHost": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"messageKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"attendees": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Attendee"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MarkNoShowOutput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/HandleMarkNoShowData"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ReserveSlotInput": {
|
"ReserveSlotInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { TestingModule } from "@nestjs/testing";
|
||||||
|
|
||||||
|
export class PlatformBillingRepositoryFixture {
|
||||||
|
private primaReadClient: PrismaReadService["prisma"];
|
||||||
|
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||||
|
|
||||||
|
constructor(private readonly module: TestingModule) {
|
||||||
|
this.primaReadClient = module.get(PrismaReadService).prisma;
|
||||||
|
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(orgId: number) {
|
||||||
|
const randomString = Date.now().toString(36);
|
||||||
|
return this.prismaWriteClient.platformBilling.create({
|
||||||
|
data: {
|
||||||
|
id: orgId,
|
||||||
|
customerId: `cus_123_${randomString}`,
|
||||||
|
subscriptionId: `sub_123_${randomString}`,
|
||||||
|
plan: "STARTER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
|
||||||
|
export class CalendarsServiceMock {
|
||||||
|
async getCalendars() {
|
||||||
|
return {
|
||||||
|
connectedCalendars: [
|
||||||
|
{
|
||||||
|
integration: {
|
||||||
|
installed: false,
|
||||||
|
type: "google_calendar",
|
||||||
|
title: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
variant: "calendar",
|
||||||
|
slug: "",
|
||||||
|
locationOption: null,
|
||||||
|
categories: ["calendar"],
|
||||||
|
logo: "",
|
||||||
|
publisher: "",
|
||||||
|
url: "",
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
credentialId: 1,
|
||||||
|
error: { message: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: {
|
||||||
|
installed: false,
|
||||||
|
type: "office365_calendar",
|
||||||
|
title: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
variant: "calendar",
|
||||||
|
slug: "",
|
||||||
|
locationOption: null,
|
||||||
|
categories: ["calendar"],
|
||||||
|
logo: "",
|
||||||
|
publisher: "",
|
||||||
|
url: "",
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
credentialId: 2,
|
||||||
|
error: { message: "" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
destinationCalendar: {
|
||||||
|
name: "destinationCalendar",
|
||||||
|
eventTypeId: 1,
|
||||||
|
credentialId: 1,
|
||||||
|
primaryEmail: "primaryEmail",
|
||||||
|
integration: "google_calendar",
|
||||||
|
externalId: "externalId",
|
||||||
|
userId: null,
|
||||||
|
id: 0,
|
||||||
|
},
|
||||||
|
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,3 +68,5 @@ public/embed
|
|||||||
|
|
||||||
# Copied app-store images
|
# Copied app-store images
|
||||||
public/app-store
|
public/app-store
|
||||||
|
|
||||||
|
certificates
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
import { WithLayout } from "app/layoutHOC";
|
import { WithLayout } from "app/layoutHOC";
|
||||||
|
|
||||||
|
import { getServerSideProps } from "@lib/event-types/getServerSideProps";
|
||||||
|
|
||||||
import EventTypes from "~/event-types/views/event-types-listing-view";
|
import EventTypes from "~/event-types/views/event-types-listing-view";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
@@ -9,4 +12,6 @@ export const generateMetadata = async () =>
|
|||||||
(t) => t("event_types_page_subtitle")
|
(t) => t("event_types_page_subtitle")
|
||||||
);
|
);
|
||||||
|
|
||||||
export default WithLayout({ getLayout: null, Page: EventTypes })<"P">;
|
const getData = withAppDirSsr(getServerSideProps);
|
||||||
|
|
||||||
|
export default WithLayout({ getLayout: null, getData, Page: EventTypes })<"P">;
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import NotFoundPage from "@pages/404";
|
import React from "react";
|
||||||
import { WithLayout } from "app/layoutHOC";
|
|
||||||
import type { GetStaticPropsContext } from "next";
|
|
||||||
|
|
||||||
import { getTranslations } from "@server/lib/getTranslations";
|
const NotFound = () => {
|
||||||
|
return (
|
||||||
const getData = async (context: GetStaticPropsContext) => {
|
<div data-testid="404-page">
|
||||||
const i18n = await getTranslations(context);
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>Sorry, the page you are looking for does not exist.</p>
|
||||||
return {
|
</div>
|
||||||
i18n,
|
);
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
export default NotFound;
|
||||||
|
|
||||||
export default WithLayout({ getLayout: null, getData, Page: NotFoundPage });
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import type { IframeHTMLAttributes } from "react";
|
import type { IframeHTMLAttributes } from "react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
|
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
|
||||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
|
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||||
|
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -22,7 +26,6 @@ export type AppPageProps = {
|
|||||||
isGlobal?: AppType["isGlobal"];
|
isGlobal?: AppType["isGlobal"];
|
||||||
logo: string;
|
logo: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
dirName: string | undefined;
|
|
||||||
variant: string;
|
variant: string;
|
||||||
body: React.ReactNode;
|
body: React.ReactNode;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
@@ -70,9 +73,9 @@ export const AppPage = ({
|
|||||||
dependencies,
|
dependencies,
|
||||||
concurrentMeetings,
|
concurrentMeetings,
|
||||||
paid,
|
paid,
|
||||||
dirName,
|
|
||||||
}: AppPageProps) => {
|
}: AppPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useCompatSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
|
|
||||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||||
@@ -80,13 +83,49 @@ export const AppPage = ({
|
|||||||
const mutation = useAddAppMutation(null, {
|
const mutation = useAddAppMutation(null, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data?.setupPending) return;
|
if (data?.setupPending) return;
|
||||||
|
setIsLoading(false);
|
||||||
showToast(t("app_successfully_installed"), "success");
|
showToast(t("app_successfully_installed"), "success");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||||
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo Refactor to eliminate the isLoading state by using mutation.isPending directly.
|
||||||
|
* Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page,
|
||||||
|
* which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes.
|
||||||
|
*/
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(mutation.isPending);
|
||||||
|
|
||||||
|
const handleAppInstall = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (isConferencing(categories)) {
|
||||||
|
mutation.mutate({
|
||||||
|
type,
|
||||||
|
variant,
|
||||||
|
slug,
|
||||||
|
returnTo:
|
||||||
|
WEBAPP_URL +
|
||||||
|
getAppOnboardingUrl({
|
||||||
|
slug,
|
||||||
|
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
!doesAppSupportTeamInstall({
|
||||||
|
appCategories: categories,
|
||||||
|
concurrentMeetings: concurrentMeetings,
|
||||||
|
isPaid: !!paid,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
mutation.mutate({ type });
|
||||||
|
} else {
|
||||||
|
router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
@@ -220,23 +259,12 @@ export const AppPage = ({
|
|||||||
props = {
|
props = {
|
||||||
...props,
|
...props,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
mutation.mutate({ type, variant, slug });
|
handleAppInstall();
|
||||||
},
|
},
|
||||||
loading: mutation.isPending,
|
loading: isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return (
|
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
|
||||||
<InstallAppButtonChild
|
|
||||||
appCategories={categories}
|
|
||||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
|
||||||
addAppMutationInput={{ type, variant, slug }}
|
|
||||||
multiInstall
|
|
||||||
concurrentMeetings={concurrentMeetings}
|
|
||||||
paid={paid}
|
|
||||||
dirName={dirName}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -260,22 +288,13 @@ export const AppPage = ({
|
|||||||
props = {
|
props = {
|
||||||
...props,
|
...props,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
mutation.mutate({ type, variant, slug });
|
handleAppInstall();
|
||||||
},
|
},
|
||||||
loading: mutation.isPending,
|
loading: isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<InstallAppButtonChild
|
<InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />
|
||||||
appCategories={categories}
|
|
||||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
|
||||||
addAppMutationInput={{ type, variant, slug }}
|
|
||||||
credentials={appDbQuery.data?.credentials}
|
|
||||||
concurrentMeetings={concurrentMeetings}
|
|
||||||
paid={paid}
|
|
||||||
dirName={dirName}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,77 +1,22 @@
|
|||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
|
||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
|
||||||
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
|
|
||||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
|
||||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
|
||||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
|
||||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
|
||||||
import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding";
|
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import type { AppFrontendPayload } from "@calcom/types/App";
|
import type { AppFrontendPayload } from "@calcom/types/App";
|
||||||
import type { ButtonProps } from "@calcom/ui";
|
import type { ButtonProps } from "@calcom/ui";
|
||||||
import {
|
import { Button } from "@calcom/ui";
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Dropdown,
|
|
||||||
DropdownItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
showToast,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
|
|
||||||
export const InstallAppButtonChild = ({
|
export const InstallAppButtonChild = ({
|
||||||
userAdminTeams,
|
|
||||||
addAppMutationInput,
|
|
||||||
appCategories,
|
|
||||||
multiInstall,
|
multiInstall,
|
||||||
credentials,
|
credentials,
|
||||||
concurrentMeetings,
|
|
||||||
dirName,
|
|
||||||
paid,
|
paid,
|
||||||
onClick,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
userAdminTeams?: UserAdminTeams;
|
|
||||||
addAppMutationInput: { type: AppFrontendPayload["type"]; variant: string; slug: string };
|
|
||||||
appCategories: string[];
|
|
||||||
multiInstall?: boolean;
|
multiInstall?: boolean;
|
||||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||||
concurrentMeetings?: boolean;
|
|
||||||
paid?: AppFrontendPayload["paid"];
|
paid?: AppFrontendPayload["paid"];
|
||||||
dirName: string | undefined;
|
|
||||||
} & ButtonProps) => {
|
} & ButtonProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const mutation = useAddAppMutation(null, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data?.setupPending) return;
|
|
||||||
showToast(t("app_successfully_installed"), "success");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||||
const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata];
|
|
||||||
const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]);
|
|
||||||
|
|
||||||
const _onClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
|
||||||
if (redirectToAppOnboarding) {
|
|
||||||
router.push(
|
|
||||||
getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP })
|
|
||||||
);
|
|
||||||
} else if (onClick) {
|
|
||||||
onClick(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Paid apps don't support team installs at the moment
|
// Paid apps don't support team installs at the moment
|
||||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||||
@@ -79,7 +24,6 @@ export const InstallAppButtonChild = ({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-testid="install-app-button"
|
data-testid="install-app-button"
|
||||||
onClick={_onClick}
|
|
||||||
{...props}
|
{...props}
|
||||||
disabled={shouldDisableInstallation}
|
disabled={shouldDisableInstallation}
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -89,97 +33,14 @@ export const InstallAppButtonChild = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!userAdminTeams?.length ||
|
|
||||||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-testid="install-app-button"
|
|
||||||
onClick={_onClick}
|
|
||||||
{...props}
|
|
||||||
// @TODO: Overriding color and size prevent us from
|
|
||||||
// having to duplicate InstallAppButton for now.
|
|
||||||
color="primary"
|
|
||||||
disabled={shouldDisableInstallation}
|
|
||||||
size="base">
|
|
||||||
{multiInstall ? t("install_another") : t("install_app")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirectToAppOnboarding) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-testid="install-app-button"
|
|
||||||
disabled={shouldDisableInstallation}
|
|
||||||
onClick={_onClick}
|
|
||||||
color="primary"
|
|
||||||
size="base"
|
|
||||||
{...props}>
|
|
||||||
{multiInstall ? t("install_another") : t("install_app")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Dropdown>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
data-testid="install-app-button"
|
||||||
<Button
|
disabled={shouldDisableInstallation}
|
||||||
data-testid="install-app-button"
|
color="primary"
|
||||||
{...props}
|
size="base"
|
||||||
// @TODO: Overriding color and size prevent us from
|
{...props}>
|
||||||
// having to duplicate InstallAppButton for now.
|
{multiInstall ? t("install_another") : t("install_app")}
|
||||||
color="primary"
|
</Button>
|
||||||
size="base">
|
|
||||||
{multiInstall ? t("install_another") : t("install_app")}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-auto"
|
|
||||||
onInteractOutside={(event) => {
|
|
||||||
if (mutation.isPending) event.preventDefault();
|
|
||||||
}}>
|
|
||||||
{mutation.isPending && (
|
|
||||||
<div className="z-1 fixed inset-0 flex items-center justify-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
|
||||||
{userAdminTeams.map((team) => {
|
|
||||||
const isInstalled =
|
|
||||||
credentials &&
|
|
||||||
credentials.some((credential) =>
|
|
||||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownItem
|
|
||||||
className="flex"
|
|
||||||
type="button"
|
|
||||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
|
||||||
key={team.id}
|
|
||||||
disabled={isInstalled}
|
|
||||||
CustomStartIcon={
|
|
||||||
<Avatar
|
|
||||||
alt={team.logoUrl || ""}
|
|
||||||
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)} // if no image, use default avatar
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
mutation.mutate(
|
|
||||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
|
||||||
);
|
|
||||||
}}>
|
|
||||||
<p>
|
|
||||||
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
|
||||||
</p>
|
|
||||||
</DropdownItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,7 +2,7 @@ import type { FC } from "react";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { Team, User } from "@calcom/prisma/client";
|
import type { Team, User } from "@calcom/prisma/client";
|
||||||
import { Avatar, StepCard } from "@calcom/ui";
|
import { Avatar, StepCard } from "@calcom/ui";
|
||||||
@@ -31,7 +31,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
|
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
|
||||||
(alreadyInstalled || loading) && "cursor-not-allowed",
|
(alreadyInstalled || loading) && "cursor-not-allowed",
|
||||||
selected && "bg-muted animate-pulse"
|
selected && loading && "bg-muted animate-pulse"
|
||||||
)}
|
)}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -42,7 +42,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt={avatar || ""}
|
alt={avatar || ""}
|
||||||
imageSrc={avatar || `${CAL_URL}/${avatar}`} // if no image, use default avatar
|
imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<div className="text-md pt-0.5 font-medium text-gray-500">
|
<div className="text-md pt-0.5 font-medium text-gray-500">
|
||||||
@@ -60,13 +60,20 @@ export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
|
|||||||
})[];
|
})[];
|
||||||
|
|
||||||
type AccountStepCardProps = {
|
type AccountStepCardProps = {
|
||||||
teams: TeamsProp;
|
teams?: TeamsProp;
|
||||||
personalAccount: PersonalAccountProps;
|
personalAccount: PersonalAccountProps;
|
||||||
onSelect: (id?: number) => void;
|
onSelect: (id?: number) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
installableOnTeams: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAccount, onSelect, loading }) => {
|
export const AccountsStepCard: FC<AccountStepCardProps> = ({
|
||||||
|
teams,
|
||||||
|
personalAccount,
|
||||||
|
onSelect,
|
||||||
|
loading,
|
||||||
|
installableOnTeams,
|
||||||
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
@@ -80,17 +87,18 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
|
|||||||
onClick={() => onSelect()}
|
onClick={() => onSelect()}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
{teams.map((team) => (
|
{installableOnTeams &&
|
||||||
<AccountSelector
|
teams?.map((team) => (
|
||||||
key={team.id}
|
<AccountSelector
|
||||||
testId={`install-app-button-team${team.id}`}
|
key={team.id}
|
||||||
alreadyInstalled={team.alreadyInstalled}
|
testId={`install-app-button-team${team.id}`}
|
||||||
avatar={team.logoUrl ?? ""}
|
alreadyInstalled={team.alreadyInstalled}
|
||||||
name={team.name}
|
avatar={team.logoUrl ?? ""}
|
||||||
onClick={() => onSelect(team.id)}
|
name={team.name}
|
||||||
loading={loading}
|
onClick={() => onSelect(team.id)}
|
||||||
/>
|
loading={loading}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
@@ -6,27 +7,34 @@ import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import type { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
import type { LocationObject } from "@calcom/core/location";
|
||||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { AppCategories } from "@calcom/prisma/enums";
|
import type { AppCategories } from "@calcom/prisma/enums";
|
||||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
import { Button, Form } from "@calcom/ui";
|
import { Button, Form } from "@calcom/ui";
|
||||||
|
|
||||||
import useAppsData from "@lib/hooks/useAppsData";
|
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
|
||||||
|
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
|
||||||
|
|
||||||
type TFormType = {
|
import { locationsResolver } from "~/event-types/views/event-types-single-view";
|
||||||
|
|
||||||
|
export type TFormType = {
|
||||||
|
id: number;
|
||||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||||
|
locations: LocationObject[];
|
||||||
|
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||||
|
seatsPerTimeSlot: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConfigureStepCardProps = {
|
export type ConfigureStepCardProps = {
|
||||||
slug: string;
|
slug: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
categories: AppCategories[];
|
categories: AppCategories[];
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
isConferencing: boolean;
|
||||||
formPortalRef: React.RefObject<HTMLDivElement>;
|
formPortalRef: React.RefObject<HTMLDivElement>;
|
||||||
eventTypes: TEventType[] | undefined;
|
eventTypes: TEventType[] | undefined;
|
||||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||||
@@ -35,51 +43,39 @@ type ConfigureStepCardProps = {
|
|||||||
|
|
||||||
type EventTypeAppSettingsFormProps = Pick<
|
type EventTypeAppSettingsFormProps = Pick<
|
||||||
ConfigureStepCardProps,
|
ConfigureStepCardProps,
|
||||||
"slug" | "userName" | "categories" | "credentialId" | "loading"
|
"slug" | "userName" | "categories" | "credentialId" | "loading" | "isConferencing"
|
||||||
> & {
|
> & {
|
||||||
eventType: TEventType;
|
eventType: TEventType;
|
||||||
handleDelete: () => void;
|
handleDelete: () => void;
|
||||||
onSubmit: (values: z.infer<typeof EventTypeMetaDataSchema>) => void;
|
onSubmit: ({
|
||||||
};
|
locations,
|
||||||
|
bookingFields,
|
||||||
type EventTypeAppSettingsWrapperProps = Pick<
|
metadata,
|
||||||
ConfigureStepCardProps,
|
}: {
|
||||||
"slug" | "userName" | "categories" | "credentialId"
|
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
|
||||||
> & {
|
bookingFields?: z.infer<typeof eventTypeBookingFields>;
|
||||||
eventType: TEventType;
|
locations?: LocationObject[];
|
||||||
};
|
}) => void;
|
||||||
|
|
||||||
const EventTypeAppSettingsWrapper: FC<EventTypeAppSettingsWrapperProps> = ({
|
|
||||||
slug,
|
|
||||||
eventType,
|
|
||||||
categories,
|
|
||||||
credentialId,
|
|
||||||
}) => {
|
|
||||||
const { getAppDataGetter, getAppDataSetter } = useAppsData();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId);
|
|
||||||
appDataSetter("enabled", true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventTypeAppSettings
|
|
||||||
slug={slug}
|
|
||||||
eventType={eventType}
|
|
||||||
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
|
|
||||||
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
||||||
function EventTypeAppSettingsForm(props, ref) {
|
function EventTypeAppSettingsForm(props, ref) {
|
||||||
const { handleDelete, onSubmit, eventType, loading } = props;
|
const { handleDelete, onSubmit, eventType, loading, isConferencing } = props;
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
const formMethods = useForm<TFormType>({
|
const formMethods = useForm<TFormType>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
metadata: eventType?.metadata,
|
id: eventType.id,
|
||||||
|
metadata: eventType?.metadata ?? undefined,
|
||||||
|
locations: eventType?.locations ?? undefined,
|
||||||
|
bookingFields: eventType?.bookingFields ?? undefined,
|
||||||
|
seatsPerTimeSlot: eventType?.seatsPerTimeSlot ?? undefined,
|
||||||
},
|
},
|
||||||
|
resolver: zodResolver(
|
||||||
|
z.object({
|
||||||
|
locations: locationsResolver(t),
|
||||||
|
})
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,8 +83,10 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
|
|||||||
form={formMethods}
|
form={formMethods}
|
||||||
id={`eventtype-${eventType.id}`}
|
id={`eventtype-${eventType.id}`}
|
||||||
handleSubmit={() => {
|
handleSubmit={() => {
|
||||||
const data = formMethods.getValues("metadata");
|
const metadata = formMethods.getValues("metadata");
|
||||||
onSubmit(data);
|
const locations = formMethods.getValues("locations");
|
||||||
|
const bookingFields = formMethods.getValues("bookingFields");
|
||||||
|
onSubmit({ metadata, locations, bookingFields });
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
|
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
|
||||||
@@ -98,13 +96,17 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
|
|||||||
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
|
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<EventTypeAppSettingsWrapper {...props} />
|
{isConferencing ? (
|
||||||
|
<EventTypeConferencingAppSettings {...props} />
|
||||||
|
) : (
|
||||||
|
<EventTypeAppSettingsWrapper {...props} />
|
||||||
|
)}
|
||||||
<X
|
<X
|
||||||
data-testid={`remove-event-type-${eventType.id}`}
|
data-testid={`remove-event-type-${eventType.id}`}
|
||||||
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
|
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
|
||||||
onClick={() => !loading && handleDelete()}
|
onClick={() => !loading && handleDelete()}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="hidden" ref={ref}>
|
<button type="submit" className="hidden" form={`eventtype-${eventType.id}`} ref={ref}>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +150,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
|||||||
if (!fields.some((field) => field.selected)) {
|
if (!fields.some((field) => field.selected)) {
|
||||||
setConfigureStep(false);
|
setConfigureStep(false);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,7 +179,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
|||||||
update(index, { ...field, selected: false, metadata: eventMetadataDb });
|
update(index, { ...field, selected: false, metadata: eventMetadataDb });
|
||||||
}}
|
}}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
update(index, { ...field, metadata: data });
|
update(index, { ...field, ...data });
|
||||||
setUpdatedEventTypesStatus((prev) =>
|
setUpdatedEventTypesStatus((prev) =>
|
||||||
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
|
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { TEventType } from "@pages/apps/installation/[[...step]]";
|
||||||
|
import { useEffect, type FC } from "react";
|
||||||
|
|
||||||
|
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
||||||
|
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||||
|
|
||||||
|
import useAppsData from "@lib/hooks/useAppsData";
|
||||||
|
|
||||||
|
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
|
||||||
|
|
||||||
|
type EventTypeAppSettingsWrapperProps = Pick<
|
||||||
|
ConfigureStepCardProps,
|
||||||
|
"slug" | "userName" | "categories" | "credentialId"
|
||||||
|
> & {
|
||||||
|
eventType: TEventType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventTypeAppSettingsWrapper: FC<EventTypeAppSettingsWrapperProps> = ({
|
||||||
|
slug,
|
||||||
|
eventType,
|
||||||
|
categories,
|
||||||
|
credentialId,
|
||||||
|
}) => {
|
||||||
|
const { getAppDataGetter, getAppDataSetter } = useAppsData();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId);
|
||||||
|
appDataSetter("enabled", true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventTypeAppSettings
|
||||||
|
slug={slug}
|
||||||
|
eventType={eventType}
|
||||||
|
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
|
||||||
|
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default EventTypeAppSettingsWrapper;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { TEventType } from "@pages/apps/installation/[[...step]]";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/client";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
|
import { Skeleton, Label } from "@calcom/ui";
|
||||||
|
|
||||||
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
|
import type { TFormType } from "@components/apps/installation/ConfigureStepCard";
|
||||||
|
import type { TLocationOptions } from "@components/eventtype/Locations";
|
||||||
|
import type { TEventTypeLocation } from "@components/eventtype/Locations";
|
||||||
|
import Locations from "@components/eventtype/Locations";
|
||||||
|
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
|
const LocationsWrapper = ({
|
||||||
|
eventType,
|
||||||
|
slug,
|
||||||
|
}: {
|
||||||
|
eventType: TEventType & {
|
||||||
|
locationOptions?: TLocationOptions;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
}) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const formMethods = useFormContext<TFormType>();
|
||||||
|
|
||||||
|
const prefillLocation = useMemo(() => {
|
||||||
|
let res: SingleValueLocationOption | undefined = undefined;
|
||||||
|
for (const item of eventType?.locationOptions || []) {
|
||||||
|
for (const option of item.options) {
|
||||||
|
if (option.slug === slug) {
|
||||||
|
res = {
|
||||||
|
...option,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}, [slug, eventType?.locationOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Skeleton as={Label} loadingClassName="w-16" htmlFor="locations">
|
||||||
|
{t("location")}
|
||||||
|
</Skeleton>
|
||||||
|
<Locations
|
||||||
|
showAppStoreLink={false}
|
||||||
|
isChildrenManagedEventType={false}
|
||||||
|
isManagedEventType={false}
|
||||||
|
disableLocationProp={false}
|
||||||
|
eventType={eventType as TEventTypeLocation}
|
||||||
|
destinationCalendar={eventType.destinationCalendar}
|
||||||
|
locationOptions={eventType.locationOptions || []}
|
||||||
|
prefillLocation={prefillLocation}
|
||||||
|
team={null}
|
||||||
|
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||||
|
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||||
|
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||||
|
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventTypeConferencingAppSettings = ({ eventType, slug }: { eventType: TEventType; slug: string }) => {
|
||||||
|
const locationsQuery = trpc.viewer.locationOptions.useQuery({});
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const SkeletonLoader = () => {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<SkeletonText className="my-2 h-8 w-full" />
|
||||||
|
</SkeletonContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryCell
|
||||||
|
query={locationsQuery}
|
||||||
|
customLoader={<SkeletonLoader />}
|
||||||
|
success={({ data }) => {
|
||||||
|
let updatedEventType: TEventType & {
|
||||||
|
locationOptions?: TLocationOptions;
|
||||||
|
} = { ...eventType };
|
||||||
|
|
||||||
|
if (updatedEventType.schedulingType === SchedulingType.MANAGED) {
|
||||||
|
updatedEventType = {
|
||||||
|
...updatedEventType,
|
||||||
|
locationOptions: [
|
||||||
|
{
|
||||||
|
label: t("default"),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: t("members_default_location"),
|
||||||
|
value: "",
|
||||||
|
icon: "/user-check.svg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...data,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
updatedEventType = { ...updatedEventType, locationOptions: data };
|
||||||
|
}
|
||||||
|
return <LocationsWrapper eventType={updatedEventType} slug={slug} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventTypeConferencingAppSettings;
|
||||||
@@ -692,7 +692,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
|||||||
|
|
||||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
showToast(t(data.message, { x: name || email }), "success");
|
showToast(
|
||||||
|
t("messageKey" in data && data.messageKey ? data.messageKey : data.message, { x: name || email }),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast(err.message, "error");
|
showToast(err.message, "error");
|
||||||
@@ -800,7 +803,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
showToast(t(data.message), "success");
|
showToast(t("messageKey" in data && data.messageKey ? data.messageKey : data.message), "success");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast(err.message, "error");
|
showToast(err.message, "error");
|
||||||
@@ -879,6 +882,69 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||||
|
const [openDropdown, setOpenDropdown] = useState(false);
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { copyToClipboard, isCopied } = useCopy();
|
||||||
|
const [selectedEmail, setSelectedEmail] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
open={openDropdown}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
setOpenDropdown(value);
|
||||||
|
setSelectedEmail("");
|
||||||
|
}}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
||||||
|
{t("plus_more", { count: guests.length - 1 })}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
|
||||||
|
{guests.slice(1).map((guest) => (
|
||||||
|
<DropdownMenuItem key={guest.id}>
|
||||||
|
<DropdownItem
|
||||||
|
className="pr-6 focus:outline-none"
|
||||||
|
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedEmail(guest.email);
|
||||||
|
}}>
|
||||||
|
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className=" flex justify-end space-x-2 p-2">
|
||||||
|
<Link href={`mailto:${selectedEmail}`}>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
disabled={selectedEmail.length === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
setOpenDropdown(false);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}>
|
||||||
|
{t("email")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
disabled={selectedEmail.length === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
copyToClipboard(selectedEmail);
|
||||||
|
showToast(t("email_copied"), "success");
|
||||||
|
}}>
|
||||||
|
{!isCopied ? t("copy") : t("copied")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const DisplayAttendees = ({
|
const DisplayAttendees = ({
|
||||||
attendees,
|
attendees,
|
||||||
@@ -911,7 +977,11 @@ const DisplayAttendees = ({
|
|||||||
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||||
</p>
|
</p>
|
||||||
))}>
|
))}>
|
||||||
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
|
{isBookingInPast ? (
|
||||||
|
<GroupedAttendees attendees={attendees} bookingUid={bookingUid} />
|
||||||
|
) : (
|
||||||
|
<GroupedGuests guests={attendees} />
|
||||||
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|
||||||
import { ErrorMessage } from "@hookform/error-message";
|
|
||||||
import { Trans } from "next-i18next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||||
import type { MultiValue } from "react-select";
|
import type { MultiValue } from "react-select";
|
||||||
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
|
||||||
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
|
||||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import { slugify } from "@calcom/lib/slugify";
|
import { slugify } from "@calcom/lib/slugify";
|
||||||
@@ -23,54 +18,12 @@ import {
|
|||||||
SettingsToggle,
|
SettingsToggle,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
TextField,
|
TextField,
|
||||||
Icon,
|
|
||||||
Editor,
|
Editor,
|
||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
Input,
|
|
||||||
PhoneInput,
|
|
||||||
Button,
|
|
||||||
showToast,
|
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
import Locations from "@components/eventtype/Locations";
|
||||||
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
|
||||||
import LocationSelect from "@components/ui/form/LocationSelect";
|
|
||||||
|
|
||||||
const getLocationFromType = (
|
|
||||||
type: EventLocationType["type"],
|
|
||||||
locationOptions: Pick<EventTypeSetupProps, "locationOptions">["locationOptions"]
|
|
||||||
) => {
|
|
||||||
for (const locationOption of locationOptions) {
|
|
||||||
const option = locationOption.options.find((option) => option.value === type);
|
|
||||||
if (option) {
|
|
||||||
return option;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLocationInfo = ({
|
|
||||||
eventType,
|
|
||||||
locationOptions,
|
|
||||||
}: Pick<EventTypeSetupProps, "eventType" | "locationOptions">) => {
|
|
||||||
const locationAvailable =
|
|
||||||
eventType.locations &&
|
|
||||||
eventType.locations.length > 0 &&
|
|
||||||
locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type));
|
|
||||||
const locationDetails = eventType.locations &&
|
|
||||||
eventType.locations.length > 0 &&
|
|
||||||
!locationAvailable && {
|
|
||||||
slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""),
|
|
||||||
name: eventType.locations[0].type
|
|
||||||
.replace("integrations:", "")
|
|
||||||
.replace(":", " ")
|
|
||||||
.replace("_video", "")
|
|
||||||
.split(" ")
|
|
||||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
||||||
.join(" "),
|
|
||||||
};
|
|
||||||
return { locationAvailable, locationDetails };
|
|
||||||
};
|
|
||||||
|
|
||||||
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
|
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
@@ -106,25 +59,13 @@ export const EventSetupTab = (
|
|||||||
) => {
|
) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { eventType, team, destinationCalendar } = props;
|
const { eventType, team } = props;
|
||||||
const [multipleDuration, setMultipleDuration] = useState(
|
const [multipleDuration, setMultipleDuration] = useState(
|
||||||
formMethods.getValues("metadata")?.multipleDuration
|
formMethods.getValues("metadata")?.multipleDuration
|
||||||
);
|
);
|
||||||
const orgBranding = useOrgBranding();
|
const orgBranding = useOrgBranding();
|
||||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||||
|
|
||||||
const locationOptions = props.locationOptions.map((locationOption) => {
|
|
||||||
const options = locationOption.options.filter((option) => {
|
|
||||||
// Skip "Organizer's Default App" for non-team members
|
|
||||||
return !team ? option.label !== t("organizer_default_conferencing_app") : true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...locationOption,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const multipleDurationOptions = [
|
const multipleDurationOptions = [
|
||||||
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||||
].map((mins) => ({
|
].map((mins) => ({
|
||||||
@@ -144,327 +85,6 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||||
const Locations = () => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const {
|
|
||||||
fields: locationFields,
|
|
||||||
append,
|
|
||||||
remove,
|
|
||||||
update: updateLocationField,
|
|
||||||
} = useFieldArray({
|
|
||||||
control: formMethods.control,
|
|
||||||
name: "locations",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
|
||||||
|
|
||||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
|
||||||
const validLocations = formMethods.getValues("locations").filter((location) => {
|
|
||||||
const eventLocation = getEventLocationType(location.type);
|
|
||||||
if (!eventLocation) {
|
|
||||||
// It's possible that the location app in use got uninstalled.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultValue = isManagedEventType
|
|
||||||
? locationOptions.find((op) => op.label === t("default"))?.options[0]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const { locationDetails, locationAvailable } = getLocationInfo(props);
|
|
||||||
|
|
||||||
const LocationInput = (props: {
|
|
||||||
eventLocationType: EventLocationType;
|
|
||||||
defaultValue?: string;
|
|
||||||
index: number;
|
|
||||||
}) => {
|
|
||||||
const { eventLocationType, index, ...remainingProps } = props;
|
|
||||||
if (eventLocationType?.organizerInputType === "text") {
|
|
||||||
const { defaultValue, ...rest } = remainingProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
|
||||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
{...(shouldLockDisableProps("locations").disabled ? { disabled: true } : {})}
|
|
||||||
className="my-0"
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
|
||||||
const { defaultValue, ...rest } = remainingProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
return (
|
|
||||||
<PhoneInput
|
|
||||||
required
|
|
||||||
disabled={shouldLockDisableProps("locations").disabled}
|
|
||||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
|
||||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
|
||||||
const defaultInitialLocation = defaultValue || null;
|
|
||||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
|
||||||
defaultInitialLocation
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<ul ref={animationRef} className="space-y-2">
|
|
||||||
{locationFields.map((field, index) => {
|
|
||||||
const eventLocationType = getEventLocationType(field.type);
|
|
||||||
const defaultLocation = field;
|
|
||||||
|
|
||||||
const option = getLocationFromType(field.type, locationOptions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={field.id}>
|
|
||||||
<div className="flex w-full items-center">
|
|
||||||
<LocationSelect
|
|
||||||
name={`locations[${index}].type`}
|
|
||||||
placeholder={t("select")}
|
|
||||||
options={locationOptions}
|
|
||||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
|
||||||
defaultValue={option}
|
|
||||||
isSearchable={false}
|
|
||||||
className="block min-w-0 flex-1 rounded-sm text-sm"
|
|
||||||
menuPlacement="auto"
|
|
||||||
onChange={(e: SingleValueLocationOption) => {
|
|
||||||
if (e?.value) {
|
|
||||||
const newLocationType = e.value;
|
|
||||||
const eventLocationType = getEventLocationType(newLocationType);
|
|
||||||
if (!eventLocationType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canAddLocation =
|
|
||||||
eventLocationType.organizerInputType ||
|
|
||||||
!validLocations.find((location) => location.type === newLocationType);
|
|
||||||
|
|
||||||
if (canAddLocation) {
|
|
||||||
updateLocationField(index, {
|
|
||||||
type: newLocationType,
|
|
||||||
...(e.credentialId && {
|
|
||||||
credentialId: e.credentialId,
|
|
||||||
teamName: e.teamName,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateLocationField(index, {
|
|
||||||
type: field.type,
|
|
||||||
...(field.credentialId && {
|
|
||||||
credentialId: field.credentialId,
|
|
||||||
teamName: field.teamName,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
showToast(t("location_already_exists"), "warning");
|
|
||||||
}
|
|
||||||
// Whenever location changes, we need to reset the locations item in booking questions list else it overflows
|
|
||||||
// previously added values resulting in wrong behaviour
|
|
||||||
const existingBookingFields = formMethods.getValues("bookingFields");
|
|
||||||
const findLocation = existingBookingFields.findIndex(
|
|
||||||
(field) => field.name === "location"
|
|
||||||
);
|
|
||||||
if (findLocation >= 0) {
|
|
||||||
existingBookingFields[findLocation] = {
|
|
||||||
...existingBookingFields[findLocation],
|
|
||||||
type: "radioInput",
|
|
||||||
label: "",
|
|
||||||
placeholder: "",
|
|
||||||
};
|
|
||||||
formMethods.setValue("bookingFields", existingBookingFields, {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!(shouldLockDisableProps("locations").disabled && isChildrenManagedEventType) && (
|
|
||||||
<button
|
|
||||||
data-testid={`delete-locations.${index}.type`}
|
|
||||||
className="min-h-9 block h-9 px-2"
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
aria-label={t("remove")}>
|
|
||||||
<div className="h-4 w-4">
|
|
||||||
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{eventLocationType?.organizerInputType && (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Icon name="corner-down-right" className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<LocationInput
|
|
||||||
defaultValue={
|
|
||||||
defaultLocation
|
|
||||||
? defaultLocation[eventLocationType.defaultValueVariable]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
eventLocationType={eventLocationType}
|
|
||||||
index={index}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ErrorMessage
|
|
||||||
errors={formMethods.formState.errors?.locations?.[index]}
|
|
||||||
name={eventLocationType.defaultValueVariable}
|
|
||||||
className="text-error my-1 ml-6 text-sm"
|
|
||||||
as="div"
|
|
||||||
id="location-error"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="ml-6">
|
|
||||||
<CheckboxField
|
|
||||||
name={`locations[${index}].displayLocationPublicly`}
|
|
||||||
data-testid="display-location"
|
|
||||||
disabled={shouldLockDisableProps("locations").disabled}
|
|
||||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
|
||||||
description={t("display_location_label")}
|
|
||||||
onChange={(e) => {
|
|
||||||
const fieldValues = formMethods.getValues("locations")[index];
|
|
||||||
updateLocationField(index, {
|
|
||||||
...fieldValues,
|
|
||||||
displayLocationPublicly: e.target.checked,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
informationIconText={t("display_location_info_badge")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
|
||||||
<div className="flex">
|
|
||||||
<LocationSelect
|
|
||||||
defaultMenuIsOpen={showEmptyLocationSelect}
|
|
||||||
placeholder={t("select")}
|
|
||||||
options={locationOptions}
|
|
||||||
value={selectedNewOption}
|
|
||||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
isSearchable={false}
|
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
|
||||||
menuPlacement="auto"
|
|
||||||
onChange={(e: SingleValueLocationOption) => {
|
|
||||||
if (e?.value) {
|
|
||||||
const newLocationType = e.value;
|
|
||||||
const eventLocationType = getEventLocationType(newLocationType);
|
|
||||||
if (!eventLocationType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canAppendLocation =
|
|
||||||
eventLocationType.organizerInputType ||
|
|
||||||
!validLocations.find((location) => location.type === newLocationType);
|
|
||||||
|
|
||||||
if (canAppendLocation) {
|
|
||||||
append({
|
|
||||||
type: newLocationType,
|
|
||||||
...(e.credentialId && {
|
|
||||||
credentialId: e.credentialId,
|
|
||||||
teamName: e.teamName,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
setSelectedNewOption(e);
|
|
||||||
} else {
|
|
||||||
showToast(t("location_already_exists"), "warning");
|
|
||||||
setSelectedNewOption(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{validLocations.some(
|
|
||||||
(location) =>
|
|
||||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
|
||||||
) && (
|
|
||||||
<div className="text-default flex items-center text-sm">
|
|
||||||
<div className="mr-1.5 h-3 w-3">
|
|
||||||
<Icon name="check" className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<Trans i18nKey="event_type_requres_google_cal">
|
|
||||||
<p>
|
|
||||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
|
||||||
Change it{" "}
|
|
||||||
<Link
|
|
||||||
href={`${WEBAPP_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
|
|
||||||
className="underline">
|
|
||||||
here.
|
|
||||||
</Link>{" "}
|
|
||||||
</p>
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
|
||||||
<p className="pl-1 text-sm leading-none text-red-600">
|
|
||||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
|
||||||
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
|
|
||||||
{t("connect_now")}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{validLocations.length > 0 && !shouldLockDisableProps("locations").disabled && (
|
|
||||||
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
|
|
||||||
<li>
|
|
||||||
<Button
|
|
||||||
data-testid="add-location"
|
|
||||||
StartIcon="plus"
|
|
||||||
color="minimal"
|
|
||||||
disabled={seatsEnabled}
|
|
||||||
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
|
|
||||||
onClick={() => setShowEmptyLocationSelect(true)}>
|
|
||||||
{t("add_location")}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<p className="text-default mt-2 text-sm">
|
|
||||||
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
|
||||||
Can't find the right video app? Visit our
|
|
||||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
|
||||||
App Store
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const lengthLockedProps = shouldLockDisableProps("length");
|
const lengthLockedProps = shouldLockDisableProps("length");
|
||||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||||
@@ -626,7 +246,19 @@ export const EventSetupTab = (
|
|||||||
name="locations"
|
name="locations"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.locations || []}
|
defaultValue={eventType.locations || []}
|
||||||
render={() => <Locations />}
|
render={() => (
|
||||||
|
<Locations
|
||||||
|
showAppStoreLink={true}
|
||||||
|
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||||
|
isManagedEventType={isManagedEventType}
|
||||||
|
disableLocationProp={shouldLockDisableProps("locations").disabled}
|
||||||
|
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||||
|
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||||
|
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||||
|
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { ErrorMessage } from "@hookform/error-message";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, useFieldArray } from "react-hook-form";
|
||||||
|
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
|
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||||
|
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Icon, Input, PhoneInput, Button, showToast } from "@calcom/ui";
|
||||||
|
|
||||||
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
|
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
||||||
|
import LocationSelect from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
|
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations">;
|
||||||
|
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["locationOptions"];
|
||||||
|
export type TDestinationCalendar = { integration: string } | null;
|
||||||
|
export type TPrefillLocation = { credentialId?: number; type: string };
|
||||||
|
|
||||||
|
type LocationsProps = {
|
||||||
|
team: { id: number } | null;
|
||||||
|
destinationCalendar: TDestinationCalendar;
|
||||||
|
showAppStoreLink: boolean;
|
||||||
|
isChildrenManagedEventType?: boolean;
|
||||||
|
isManagedEventType?: boolean;
|
||||||
|
disableLocationProp?: boolean;
|
||||||
|
getValues: UseFormGetValues<LocationFormValues>;
|
||||||
|
setValue: UseFormSetValue<LocationFormValues>;
|
||||||
|
control: Control<LocationFormValues>;
|
||||||
|
formState: FormState<LocationFormValues>;
|
||||||
|
eventType: TEventTypeLocation;
|
||||||
|
locationOptions: TLocationOptions;
|
||||||
|
prefillLocation?: SingleValueLocationOption;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationFromType = (type: EventLocationType["type"], locationOptions: TLocationOptions) => {
|
||||||
|
for (const locationOption of locationOptions) {
|
||||||
|
const option = locationOption.options.find((option) => option.value === type);
|
||||||
|
if (option) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocationInfo = ({
|
||||||
|
eventType,
|
||||||
|
locationOptions,
|
||||||
|
}: {
|
||||||
|
eventType: TEventTypeLocation;
|
||||||
|
locationOptions: TLocationOptions;
|
||||||
|
}) => {
|
||||||
|
const locationAvailable =
|
||||||
|
eventType.locations &&
|
||||||
|
eventType.locations.length > 0 &&
|
||||||
|
locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type));
|
||||||
|
const locationDetails = eventType.locations &&
|
||||||
|
eventType.locations.length > 0 &&
|
||||||
|
!locationAvailable && {
|
||||||
|
slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""),
|
||||||
|
name: eventType.locations[0].type
|
||||||
|
.replace("integrations:", "")
|
||||||
|
.replace(":", " ")
|
||||||
|
.replace("_video", "")
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||||
|
.join(" "),
|
||||||
|
};
|
||||||
|
return { locationAvailable, locationDetails };
|
||||||
|
};
|
||||||
|
|
||||||
|
const Locations: React.FC<LocationsProps> = ({
|
||||||
|
isChildrenManagedEventType,
|
||||||
|
disableLocationProp,
|
||||||
|
isManagedEventType,
|
||||||
|
getValues,
|
||||||
|
setValue,
|
||||||
|
control,
|
||||||
|
formState,
|
||||||
|
team,
|
||||||
|
eventType,
|
||||||
|
prefillLocation,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const {
|
||||||
|
fields: locationFields,
|
||||||
|
append,
|
||||||
|
remove,
|
||||||
|
update: updateLocationField,
|
||||||
|
} = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "locations",
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationOptions = props.locationOptions.map((locationOption) => {
|
||||||
|
const options = locationOption.options.filter((option) => {
|
||||||
|
// Skip "Organizer's Default App" for non-team members
|
||||||
|
return !team?.id ? option.label !== t("organizer_default_conferencing_app") : true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...locationOption,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
|
const seatsEnabled = !!getValues("seatsPerTimeSlot");
|
||||||
|
|
||||||
|
const validLocations =
|
||||||
|
getValues("locations")?.filter((location) => {
|
||||||
|
const eventLocation = getEventLocationType(location.type);
|
||||||
|
if (!eventLocation) {
|
||||||
|
// It's possible that the location app in use got uninstalled.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const defaultValue = isManagedEventType
|
||||||
|
? locationOptions.find((op) => op.label === t("default"))?.options[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { locationDetails, locationAvailable } = getLocationInfo({
|
||||||
|
eventType,
|
||||||
|
locationOptions: props.locationOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const LocationInput = (props: {
|
||||||
|
eventLocationType: EventLocationType;
|
||||||
|
defaultValue?: string;
|
||||||
|
index: number;
|
||||||
|
}) => {
|
||||||
|
const { eventLocationType, index, ...remainingProps } = props;
|
||||||
|
if (eventLocationType?.organizerInputType === "text") {
|
||||||
|
const { defaultValue, ...rest } = remainingProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
render={({ field: { onChange, value } }) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||||
|
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
{...(disableLocationProp ? { disabled: true } : {})}
|
||||||
|
className="my-0"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||||
|
const { defaultValue, ...rest } = remainingProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
render={({ field: { onChange, value } }) => {
|
||||||
|
return (
|
||||||
|
<PhoneInput
|
||||||
|
required
|
||||||
|
disabled={disableLocationProp}
|
||||||
|
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||||
|
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||||
|
const defaultInitialLocation = defaultValue || null;
|
||||||
|
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||||
|
defaultInitialLocation
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!!prefillLocation) {
|
||||||
|
const newLocationType = prefillLocation.value;
|
||||||
|
|
||||||
|
const canAppendLocation = !validLocations.find((location) => location.type === newLocationType);
|
||||||
|
|
||||||
|
if (canAppendLocation && !seatsEnabled) {
|
||||||
|
append({
|
||||||
|
type: newLocationType,
|
||||||
|
credentialId: prefillLocation?.credentialId,
|
||||||
|
});
|
||||||
|
setSelectedNewOption(prefillLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [prefillLocation, seatsEnabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<ul ref={animationRef} className="space-y-2">
|
||||||
|
{locationFields.map((field, index) => {
|
||||||
|
const eventLocationType = getEventLocationType(field.type);
|
||||||
|
const defaultLocation = field;
|
||||||
|
|
||||||
|
const option = getLocationFromType(field.type, locationOptions);
|
||||||
|
return (
|
||||||
|
<li key={field.id}>
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
<LocationSelect
|
||||||
|
name={`locations[${index}].type`}
|
||||||
|
placeholder={t("select")}
|
||||||
|
options={locationOptions}
|
||||||
|
isDisabled={disableLocationProp}
|
||||||
|
defaultValue={option}
|
||||||
|
isSearchable={false}
|
||||||
|
className="block min-w-0 flex-1 rounded-sm text-sm"
|
||||||
|
menuPlacement="auto"
|
||||||
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
|
setShowEmptyLocationSelect(false);
|
||||||
|
if (e?.value) {
|
||||||
|
const newLocationType = e.value;
|
||||||
|
const eventLocationType = getEventLocationType(newLocationType);
|
||||||
|
if (!eventLocationType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canAddLocation =
|
||||||
|
eventLocationType.organizerInputType ||
|
||||||
|
!validLocations?.find((location) => location.type === newLocationType);
|
||||||
|
|
||||||
|
if (canAddLocation) {
|
||||||
|
updateLocationField(index, {
|
||||||
|
type: newLocationType,
|
||||||
|
...(e.credentialId && {
|
||||||
|
credentialId: e.credentialId,
|
||||||
|
teamName: e.teamName ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateLocationField(index, {
|
||||||
|
type: field.type,
|
||||||
|
...(field.credentialId && {
|
||||||
|
credentialId: field.credentialId,
|
||||||
|
teamName: field.teamName ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
showToast(t("location_already_exists"), "warning");
|
||||||
|
}
|
||||||
|
// Whenever location changes, we need to reset the locations item in booking questions list else it overflows
|
||||||
|
// previously added values resulting in wrong behaviour
|
||||||
|
const existingBookingFields = getValues("bookingFields");
|
||||||
|
const findLocation = existingBookingFields.findIndex(
|
||||||
|
(field) => field.name === "location"
|
||||||
|
);
|
||||||
|
if (findLocation >= 0) {
|
||||||
|
existingBookingFields[findLocation] = {
|
||||||
|
...existingBookingFields[findLocation],
|
||||||
|
type: "radioInput",
|
||||||
|
label: "",
|
||||||
|
placeholder: "",
|
||||||
|
};
|
||||||
|
setValue("bookingFields", existingBookingFields, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!(disableLocationProp && isChildrenManagedEventType) && (
|
||||||
|
<button
|
||||||
|
data-testid={`delete-locations.${index}.type`}
|
||||||
|
className="min-h-9 block h-9 px-2"
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
aria-label={t("remove")}>
|
||||||
|
<div className="h-4 w-4">
|
||||||
|
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eventLocationType?.organizerInputType && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Icon name="corner-down-right" className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<LocationInput
|
||||||
|
data-testid={`${eventLocationType.type}-location-input`}
|
||||||
|
defaultValue={
|
||||||
|
defaultLocation
|
||||||
|
? defaultLocation[eventLocationType.defaultValueVariable]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
eventLocationType={eventLocationType}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage
|
||||||
|
errors={formState.errors?.locations?.[index]}
|
||||||
|
name={eventLocationType.defaultValueVariable}
|
||||||
|
className="text-error my-1 ml-6 text-sm"
|
||||||
|
as="div"
|
||||||
|
id="location-error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-6">
|
||||||
|
<CheckboxField
|
||||||
|
name={`locations[${index}].displayLocationPublicly`}
|
||||||
|
data-testid="display-location"
|
||||||
|
disabled={disableLocationProp}
|
||||||
|
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||||
|
description={t("display_location_label")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const fieldValues = getValues("locations")[index];
|
||||||
|
updateLocationField(index, {
|
||||||
|
...fieldValues,
|
||||||
|
displayLocationPublicly: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
informationIconText={t("display_location_info_badge")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
||||||
|
<div className="flex">
|
||||||
|
<LocationSelect
|
||||||
|
defaultMenuIsOpen={showEmptyLocationSelect}
|
||||||
|
placeholder={t("select")}
|
||||||
|
options={locationOptions}
|
||||||
|
value={selectedNewOption}
|
||||||
|
isDisabled={disableLocationProp}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
isSearchable={false}
|
||||||
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
|
menuPlacement="auto"
|
||||||
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
|
setShowEmptyLocationSelect(false);
|
||||||
|
if (e?.value) {
|
||||||
|
const newLocationType = e.value;
|
||||||
|
const eventLocationType = getEventLocationType(newLocationType);
|
||||||
|
if (!eventLocationType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAppendLocation =
|
||||||
|
eventLocationType.organizerInputType ||
|
||||||
|
!validLocations.find((location) => location.type === newLocationType);
|
||||||
|
|
||||||
|
if (canAppendLocation) {
|
||||||
|
append({
|
||||||
|
type: newLocationType,
|
||||||
|
...(e.credentialId && {
|
||||||
|
credentialId: e.credentialId,
|
||||||
|
teamName: e.teamName ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSelectedNewOption(e);
|
||||||
|
} else {
|
||||||
|
showToast(t("location_already_exists"), "warning");
|
||||||
|
setSelectedNewOption(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validLocations.some(
|
||||||
|
(location) =>
|
||||||
|
location.type === MeetLocationType && props.destinationCalendar?.integration !== "google_calendar"
|
||||||
|
) && (
|
||||||
|
<div className="text-default flex items-center text-sm">
|
||||||
|
<div className="mr-1.5 h-3 w-3">
|
||||||
|
<Icon name="check" className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<p className="text-default text-sm">
|
||||||
|
<Trans i18nKey="event_type_requires_google_calendar">
|
||||||
|
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||||
|
Connect it
|
||||||
|
<Link className="cursor-pointer text-blue-500 underline" href="/apps/google-calendar">
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||||
|
<p className="pl-1 text-sm leading-none text-red-600">
|
||||||
|
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||||
|
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
|
||||||
|
{t("connect_now")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{validLocations.length > 0 && !disableLocationProp && (
|
||||||
|
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
|
||||||
|
<li>
|
||||||
|
<Button
|
||||||
|
data-testid="add-location"
|
||||||
|
StartIcon="plus"
|
||||||
|
color="minimal"
|
||||||
|
disabled={seatsEnabled}
|
||||||
|
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
|
||||||
|
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||||
|
{t("add_location")}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
{props.showAppStoreLink && (
|
||||||
|
<p className="text-default mt-2 text-sm">
|
||||||
|
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||||
|
Can't find the right video app? Visit our
|
||||||
|
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||||
|
App Store
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Locations;
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
|
||||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import { Button } from "@calcom/ui";
|
import { Badge, Button, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
interface IAppConnectionItem {
|
interface IAppConnectionItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,26 +14,74 @@ interface IAppConnectionItem {
|
|||||||
logo: string;
|
logo: string;
|
||||||
type: App["type"];
|
type: App["type"];
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
defaultInstall?: boolean;
|
||||||
|
slug?: string;
|
||||||
|
dependencyData?: TDependencyData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppConnectionItem = (props: IAppConnectionItem) => {
|
const AppConnectionItem = (props: IAppConnectionItem) => {
|
||||||
const { title, logo, type, installed } = props;
|
const { title, logo, type, installed, isDefault, defaultInstall, slug } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation();
|
||||||
|
const dependency = props.dependencyData?.find((data) => !data.installed);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-center justify-between p-5">
|
<div className="flex flex-row items-center justify-between p-5">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<img src={logo} alt={title} className="h-8 w-8" />
|
<img src={logo} alt={title} className="h-8 w-8" />
|
||||||
<p className="text-sm font-bold">{title}</p>
|
<p className="text-sm font-bold">{title}</p>
|
||||||
|
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<InstallAppButtonWithoutPlanCheck
|
<InstallAppButtonWithoutPlanCheck
|
||||||
type={type}
|
type={type}
|
||||||
|
options={{
|
||||||
|
onSuccess: () => {
|
||||||
|
if (defaultInstall && slug) {
|
||||||
|
setDefaultConferencingApp.mutate({ slug });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
render={(buttonProps) => (
|
render={(buttonProps) => (
|
||||||
<Button
|
<Button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
disabled={installed}
|
disabled={installed || !!dependency}
|
||||||
type="button"
|
type="button"
|
||||||
loading={buttonProps?.isPending}
|
loading={buttonProps?.isPending}
|
||||||
|
tooltip={
|
||||||
|
dependency ? (
|
||||||
|
<div className="items-start space-x-2.5">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div>
|
||||||
|
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold">
|
||||||
|
{t("this_app_requires_connected_account", {
|
||||||
|
appName: title,
|
||||||
|
dependencyName: dependency.name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`${WEBAPP_URL}/getting-started/connected-calendar`}
|
||||||
|
className="flex items-center text-xs underline">
|
||||||
|
<span className="mr-1">
|
||||||
|
{t("connect_app", { dependencyName: dependency.name })}
|
||||||
|
</span>
|
||||||
|
<Icon name="arrow-right" className="inline-block h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// Save cookie key to return url step
|
// Save cookie key to return url step
|
||||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
import { Icon, List } from "@calcom/ui";
|
import { Icon, List } from "@calcom/ui";
|
||||||
|
|
||||||
import { AppConnectionItem } from "../components/AppConnectionItem";
|
import { AppConnectionItem } from "../components/AppConnectionItem";
|
||||||
@@ -17,12 +19,16 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
|||||||
onlyInstalled: false,
|
onlyInstalled: false,
|
||||||
sortByMostPopular: true,
|
sortByMostPopular: true,
|
||||||
});
|
});
|
||||||
|
const { data } = useMeQuery();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const metadata = userMetadata.parse(data?.metadata);
|
||||||
|
|
||||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||||
(item) => item.userCredentialIds.length > 0
|
(item) => item.userCredentialIds.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
@@ -36,9 +42,15 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
|||||||
<AppConnectionItem
|
<AppConnectionItem
|
||||||
type={item.type}
|
type={item.type}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
|
isDefault={item.slug === defaultConferencingApp}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
|
dependencyData={item.dependencyData}
|
||||||
logo={item.logo}
|
logo={item.logo}
|
||||||
|
slug={item.slug}
|
||||||
installed={item.userCredentialIds.length > 0}
|
installed={item.userCredentialIds.length > 0}
|
||||||
|
defaultInstall={
|
||||||
|
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { debounce, noop } from "lodash";
|
import { noop } from "lodash";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import type { RefCallback } from "react";
|
import type { RefCallback } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||||
|
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
@@ -71,17 +72,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
const isCurrentUsernamePremium =
|
const isCurrentUsernamePremium =
|
||||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||||
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
||||||
const debouncedApiCall = useMemo(
|
// debounce the username input, set the delay to 600ms to be consistent with signup form
|
||||||
() =>
|
const debouncedUsername = useDebounce(inputUsernameValue, 600);
|
||||||
debounce(async (username: string) => {
|
|
||||||
// TODO: Support orgSlug
|
|
||||||
const { data } = await fetchUsername(username, null);
|
|
||||||
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
|
|
||||||
setIsInputUsernamePremium(data.premium);
|
|
||||||
setUsernameIsAvailable(data.available);
|
|
||||||
}, 150),
|
|
||||||
[currentUsername]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use the current username or if it's not set, use the one available from stripe
|
// Use the current username or if it's not set, use the one available from stripe
|
||||||
@@ -89,12 +81,22 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
|
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inputUsernameValue) {
|
async function checkUsername(username: string | undefined) {
|
||||||
debouncedApiCall.cancel();
|
if (!username) {
|
||||||
return;
|
setUsernameIsAvailable(false);
|
||||||
|
setMarkAsError(false);
|
||||||
|
setIsInputUsernamePremium(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await fetchUsername(username, null);
|
||||||
|
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
|
||||||
|
setIsInputUsernamePremium(data.premium);
|
||||||
|
setUsernameIsAvailable(data.available);
|
||||||
}
|
}
|
||||||
debouncedApiCall(inputUsernameValue);
|
|
||||||
}, [debouncedApiCall, inputUsernameValue]);
|
checkUsername(debouncedUsername);
|
||||||
|
}, [debouncedUsername, currentUsername]);
|
||||||
|
|
||||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { debounce, noop } from "lodash";
|
import { noop } from "lodash";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import type { RefCallback } from "react";
|
import type { RefCallback } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
|
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -41,31 +42,28 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
const [markAsError, setMarkAsError] = useState(false);
|
const [markAsError, setMarkAsError] = useState(false);
|
||||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||||
|
|
||||||
const debouncedApiCall = useMemo(
|
// debounce the username input, set the delay to 600ms to be consistent with signup form
|
||||||
() =>
|
const debouncedUsername = useDebounce(inputUsernameValue, 600);
|
||||||
debounce(async (username) => {
|
|
||||||
// TODO: Support orgSlug
|
useEffect(() => {
|
||||||
|
async function checkUsername(username: string | undefined) {
|
||||||
|
if (!username) {
|
||||||
|
setUsernameIsAvailable(false);
|
||||||
|
setMarkAsError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUsername !== username) {
|
||||||
const { data } = await fetchUsername(username, null);
|
const { data } = await fetchUsername(username, null);
|
||||||
setMarkAsError(!data.available);
|
setMarkAsError(!data.available);
|
||||||
setUsernameIsAvailable(data.available);
|
setUsernameIsAvailable(data.available);
|
||||||
}, 150),
|
} else {
|
||||||
[]
|
setUsernameIsAvailable(false);
|
||||||
);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inputUsernameValue) {
|
|
||||||
debouncedApiCall.cancel();
|
|
||||||
setUsernameIsAvailable(false);
|
|
||||||
setMarkAsError(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUsername !== inputUsernameValue) {
|
checkUsername(debouncedUsername);
|
||||||
debouncedApiCall(inputUsernameValue);
|
}, [debouncedUsername, currentUsername]);
|
||||||
} else {
|
|
||||||
setUsernameIsAvailable(false);
|
|
||||||
}
|
|
||||||
}, [inputUsernameValue, debouncedApiCall, currentUsername]);
|
|
||||||
|
|
||||||
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
|
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|||||||
@@ -39,13 +39,17 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
|||||||
Option: (props) => {
|
Option: (props) => {
|
||||||
return (
|
return (
|
||||||
<components.Option {...props}>
|
<components.Option {...props}>
|
||||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
<div data-testid={`location-select-item-${props.data.value}`}>
|
||||||
|
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||||
|
</div>
|
||||||
</components.Option>
|
</components.Option>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
SingleValue: (props) => (
|
SingleValue: (props) => (
|
||||||
<components.SingleValue {...props}>
|
<components.SingleValue {...props}>
|
||||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
<div data-testid={`location-select-item-${props.data.value}`}>
|
||||||
|
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||||
|
</div>
|
||||||
</components.SingleValue>
|
</components.SingleValue>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
|
|
||||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
|
||||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
import type { AppCategories } from "@calcom/prisma/enums";
|
import type { AppCategories } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
@@ -17,7 +17,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
|
|
||||||
let appStore, userAdminTeams: UserAdminTeams;
|
let appStore, userAdminTeams: UserAdminTeams;
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
userAdminTeams = await getUserAdminTeams({ userId: session.user.id, getUserInfo: true });
|
userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
|
||||||
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
|
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
|
||||||
} else {
|
} else {
|
||||||
appStore = await getAppRegistry();
|
appStore = await getAppRegistry();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||||
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||||
|
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
@@ -61,10 +62,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
|||||||
workflows: {
|
workflows: {
|
||||||
select: {
|
select: {
|
||||||
workflow: {
|
workflow: {
|
||||||
select: {
|
select: workflowSelect,
|
||||||
id: true,
|
|
||||||
steps: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
eventSlug: slug,
|
eventSlug: slug,
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
org,
|
org,
|
||||||
|
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!eventData) {
|
if (!eventData) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
|
||||||
|
|
||||||
|
// TODO: use BookingRepository
|
||||||
|
export const getBooking = async (bookingId: number) => {
|
||||||
|
const booking = await prisma.booking.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id: bookingId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...bookingMinimalSelect,
|
||||||
|
uid: true,
|
||||||
|
location: true,
|
||||||
|
isRecorded: true,
|
||||||
|
eventTypeId: true,
|
||||||
|
eventType: {
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
parentId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
timeZone: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
locale: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
log.error(
|
||||||
|
"Couldn't find Booking Id:",
|
||||||
|
safeStringify({
|
||||||
|
bookingId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new HttpError({
|
||||||
|
message: `Booking of id ${bookingId} does not exist or does not contain daily video as location`,
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return booking;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type getBookingResponse = Awaited<ReturnType<typeof getBooking>>;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference";
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
|
||||||
|
|
||||||
|
export const getBookingReference = async (roomName: string) => {
|
||||||
|
const bookingReference = await BookingReferenceRepository.findDailyVideoReferenceByRoomName({ roomName });
|
||||||
|
|
||||||
|
if (!bookingReference || !bookingReference.bookingId) {
|
||||||
|
log.error(
|
||||||
|
"bookingReference not found error:",
|
||||||
|
safeStringify({
|
||||||
|
bookingReference,
|
||||||
|
roomName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new HttpError({ message: "Booking reference not found", statusCode: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookingReference;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
|
import type { getBookingResponse } from "./getBooking";
|
||||||
|
|
||||||
|
export const getCalendarEvent = async (booking: getBookingResponse) => {
|
||||||
|
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
|
||||||
|
|
||||||
|
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||||
|
return {
|
||||||
|
id: attendee.id,
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: {
|
||||||
|
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||||
|
locale: attendee.locale ?? "en",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendeesList = await Promise.all(attendeesListPromises);
|
||||||
|
const evt: CalendarEvent = {
|
||||||
|
type: booking.title,
|
||||||
|
title: booking.title,
|
||||||
|
description: booking.description || undefined,
|
||||||
|
startTime: booking.startTime.toISOString(),
|
||||||
|
endTime: booking.endTime.toISOString(),
|
||||||
|
organizer: {
|
||||||
|
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||||
|
name: booking.user?.name || "Nameless",
|
||||||
|
timeZone: booking.user?.timeZone || "Europe/London",
|
||||||
|
language: { translate: t, locale: booking?.user?.locale ?? "en" },
|
||||||
|
},
|
||||||
|
attendees: attendeesList,
|
||||||
|
uid: booking.uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Promise.resolve(evt);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const commonSchema = z
|
||||||
|
.object({
|
||||||
|
version: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
event_ts: z.number().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const meetingEndedSchema = commonSchema.extend({
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
meeting_id: z.string(),
|
||||||
|
end_ts: z.number().optional(),
|
||||||
|
room: z.string(),
|
||||||
|
start_ts: z.number().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const recordingReadySchema = commonSchema.extend({
|
||||||
|
payload: z.object({
|
||||||
|
recording_id: z.string(),
|
||||||
|
end_ts: z.number().optional(),
|
||||||
|
room_name: z.string(),
|
||||||
|
start_ts: z.number().optional(),
|
||||||
|
status: z.string(),
|
||||||
|
|
||||||
|
max_participants: z.number().optional(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
s3_key: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const batchProcessorJobFinishedSchema = commonSchema.extend({
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
input: z.object({
|
||||||
|
sourceType: z.string(),
|
||||||
|
recordingId: z.string(),
|
||||||
|
}),
|
||||||
|
output: z
|
||||||
|
.object({
|
||||||
|
transcription: z.array(z.object({ format: z.string() }).passthrough()),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TBatchProcessorJobFinished = z.infer<typeof batchProcessorJobFinishedSchema>;
|
||||||
|
|
||||||
|
export const downloadLinkSchema = z.object({
|
||||||
|
download_link: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const testRequestSchema = z.object({
|
||||||
|
test: z.enum(["test"]),
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
createBookingScenario,
|
||||||
|
getScenarioData,
|
||||||
|
TestData,
|
||||||
|
getDate,
|
||||||
|
getMockBookingAttendee,
|
||||||
|
getOrganizer,
|
||||||
|
getBooker,
|
||||||
|
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||||
|
import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, afterEach, test, vi, beforeEach, beforeAll } from "vitest";
|
||||||
|
|
||||||
|
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
|
||||||
|
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
|
||||||
|
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||||
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
import handler from "@calcom/web/pages/api/recorded-daily-video";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
beforeAll(() => {
|
||||||
|
// Setup env vars
|
||||||
|
vi.stubEnv("SENDGRID_API_KEY", "FAKE_SENDGRID_API_KEY");
|
||||||
|
vi.stubEnv("SENDGRID_EMAIL", "FAKE_SENDGRID_EMAIL");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@calcom/app-store/dailyvideo/lib", () => {
|
||||||
|
return {
|
||||||
|
getRoomNameFromRecordingId: vi.fn(),
|
||||||
|
getBatchProcessorJobAccessLink: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@calcom/core/videoClient", () => {
|
||||||
|
return {
|
||||||
|
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD = {
|
||||||
|
version: "1.1.0",
|
||||||
|
type: "batch-processor.job-finished",
|
||||||
|
id: "77b1cb9e-cd79-43cd-bad6-3ccaccba26be",
|
||||||
|
payload: {
|
||||||
|
id: "77b1cb9e-cd79-43cd-bad6-3ccaccba26be",
|
||||||
|
status: "finished",
|
||||||
|
input: {
|
||||||
|
sourceType: "recordingId",
|
||||||
|
recordingId: "eb9e84de-783e-4e14-875d-94700ee4b976",
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
transcription: [
|
||||||
|
{
|
||||||
|
format: "json",
|
||||||
|
s3Config: {
|
||||||
|
key: "transcript.json",
|
||||||
|
bucket: "daily-bucket",
|
||||||
|
region: "us-west-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "srt",
|
||||||
|
s3Config: {
|
||||||
|
key: "transcript.srt",
|
||||||
|
bucket: "daily-bucket",
|
||||||
|
region: "us-west-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "txt",
|
||||||
|
s3Config: {
|
||||||
|
key: "transcript.txt",
|
||||||
|
bucket: "daily-bucket",
|
||||||
|
region: "us-west-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "vtt",
|
||||||
|
s3Config: {
|
||||||
|
key: "transcript.vtt",
|
||||||
|
bucket: "daily-bucket",
|
||||||
|
region: "us-west-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event_ts: 1717688213.803,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = process.env.CI ? 5000 : 20000;
|
||||||
|
|
||||||
|
const TRANSCRIPTION_ACCESS_LINK = {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
preset: "transcript",
|
||||||
|
status: "finished",
|
||||||
|
transcription: [
|
||||||
|
{
|
||||||
|
format: "json",
|
||||||
|
link: "https://download.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "srt",
|
||||||
|
link: "https://download.srt",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Handler: /api/recorded-daily-video", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
`Batch Processor Job finished triggers RECORDING_TRANSCRIPTION_GENERATED webhooks`,
|
||||||
|
async () => {
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingUid = "n5Wv3eHgconAED2j4gcVhP";
|
||||||
|
const iCalUID = `${bookingUid}@Cal.com`;
|
||||||
|
const subscriberUrl = "http://my-webhook.example.com";
|
||||||
|
const recordingDownloadLink = "https://download-link.com";
|
||||||
|
|
||||||
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
userId: organizer.id,
|
||||||
|
eventTriggers: [WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED],
|
||||||
|
subscriberUrl,
|
||||||
|
active: true,
|
||||||
|
eventTypeId: 1,
|
||||||
|
appId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 15,
|
||||||
|
length: 15,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
uid: bookingUid,
|
||||||
|
eventTypeId: 1,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||||
|
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||||
|
userId: organizer.id,
|
||||||
|
metadata: {
|
||||||
|
videoCallUrl: "https://existing-daily-video-call-url.example.com",
|
||||||
|
},
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
credentialId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attendees: [
|
||||||
|
getMockBookingAttendee({
|
||||||
|
id: 2,
|
||||||
|
name: booker.name,
|
||||||
|
email: booker.email,
|
||||||
|
locale: "en",
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
noShow: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
iCalUID,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
apps: [TestData.apps["daily-video"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID");
|
||||||
|
vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK);
|
||||||
|
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
|
||||||
|
download_link: recordingDownloadLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD,
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
await expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
||||||
|
triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
|
||||||
|
payload: {
|
||||||
|
type: "Test Booking Title",
|
||||||
|
uid: bookingUid,
|
||||||
|
downloadLinks: {
|
||||||
|
transcription: TRANSCRIPTION_ACCESS_LINK.transcription,
|
||||||
|
recording: recordingDownloadLink,
|
||||||
|
},
|
||||||
|
organizer: {
|
||||||
|
email: organizer.email,
|
||||||
|
name: organizer.name,
|
||||||
|
timeZone: organizer.timeZone,
|
||||||
|
language: { locale: "en" },
|
||||||
|
utcOffset: 330,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
|
||||||
|
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||||
|
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||||
|
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||||
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler:triggerRecordingReadyWebhook"] });
|
||||||
|
|
||||||
|
type Booking = {
|
||||||
|
userId: number | undefined;
|
||||||
|
eventTypeId: number | null;
|
||||||
|
eventTypeParentId: number | null | undefined;
|
||||||
|
teamId?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWebhooksByEventTrigger = async (eventTrigger: WebhookTriggerEvents, booking: Booking) => {
|
||||||
|
const isTeamBooking = booking.teamId;
|
||||||
|
const isBookingForManagedEventtype = booking.teamId && booking.eventTypeParentId;
|
||||||
|
const triggerForUser = !isTeamBooking || isBookingForManagedEventtype;
|
||||||
|
const organizerUserId = triggerForUser ? booking.userId : null;
|
||||||
|
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
|
||||||
|
|
||||||
|
const subscriberOptions = {
|
||||||
|
userId: organizerUserId,
|
||||||
|
eventTypeId: booking.eventTypeId,
|
||||||
|
triggerEvent: eventTrigger,
|
||||||
|
teamId: booking.teamId,
|
||||||
|
orgId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return getWebhooks(subscriberOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triggerRecordingReadyWebhook = async ({
|
||||||
|
evt,
|
||||||
|
downloadLink,
|
||||||
|
booking,
|
||||||
|
}: {
|
||||||
|
evt: CalendarEvent;
|
||||||
|
downloadLink: string;
|
||||||
|
booking: Booking;
|
||||||
|
}) => {
|
||||||
|
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
||||||
|
const webhooks = await getWebhooksByEventTrigger(eventTrigger, booking);
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Webhooks:",
|
||||||
|
safeStringify({
|
||||||
|
webhooks,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const promises = webhooks.map((webhook) =>
|
||||||
|
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
|
||||||
|
...evt,
|
||||||
|
downloadLink,
|
||||||
|
}).catch((e) => {
|
||||||
|
log.error(
|
||||||
|
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
|
||||||
|
safeStringify(e)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triggerTranscriptionGeneratedWebhook = async ({
|
||||||
|
evt,
|
||||||
|
downloadLinks,
|
||||||
|
booking,
|
||||||
|
}: {
|
||||||
|
evt: CalendarEvent;
|
||||||
|
downloadLinks?: {
|
||||||
|
transcription: TGetTranscriptAccessLink["transcription"];
|
||||||
|
recording: string;
|
||||||
|
};
|
||||||
|
booking: Booking;
|
||||||
|
}) => {
|
||||||
|
const webhooks = await getWebhooksByEventTrigger(
|
||||||
|
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
|
||||||
|
booking
|
||||||
|
);
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Webhooks:",
|
||||||
|
safeStringify({
|
||||||
|
webhooks,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const promises = webhooks.map((webhook) =>
|
||||||
|
sendPayload(
|
||||||
|
webhook.secret,
|
||||||
|
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
|
||||||
|
new Date().toISOString(),
|
||||||
|
webhook,
|
||||||
|
{
|
||||||
|
...evt,
|
||||||
|
downloadLinks,
|
||||||
|
}
|
||||||
|
).catch((e) => {
|
||||||
|
log.error(
|
||||||
|
`Error executing webhook for event: ${WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
|
||||||
|
safeStringify(e)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
};
|
||||||
@@ -186,7 +186,7 @@ export default function Success(props: PageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (noShow) {
|
if (noShow) {
|
||||||
noShowMutation.mutate({ bookingUid: bookingInfo.uid });
|
noShowMutation.mutate({ bookingUid: bookingInfo.uid, noShowHost: true });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -296,7 +296,7 @@ export default function Success(props: PageProps) {
|
|||||||
function getTitle(): string {
|
function getTitle(): string {
|
||||||
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
||||||
const titlePrefix = isRoundRobin ? "round_robin_" : "";
|
const titlePrefix = isRoundRobin ? "round_robin_" : "";
|
||||||
if (isCancelled) {
|
if (isCancelled || isBookingInPast) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (needsConfirmation) {
|
if (needsConfirmation) {
|
||||||
@@ -340,6 +340,8 @@ export default function Success(props: PageProps) {
|
|||||||
|
|
||||||
const providerName = guessEventLocationType(location)?.label;
|
const providerName = guessEventLocationType(location)?.label;
|
||||||
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
|
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
|
||||||
|
const isBookingInPast = new Date(bookingInfo.endTime) < new Date();
|
||||||
|
const isReschedulable = !isCancelled && !isBookingInPast;
|
||||||
|
|
||||||
const bookingCancelledEventProps = {
|
const bookingCancelledEventProps = {
|
||||||
booking: bookingInfo,
|
booking: bookingInfo,
|
||||||
@@ -412,7 +414,7 @@ export default function Success(props: PageProps) {
|
|||||||
imageSrc={`${bookingInfo.user.avatarUrl}`}
|
imageSrc={`${bookingInfo.user.avatarUrl}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{giphyImage && !needsConfirmation && !isCancelled && (
|
{giphyImage && !needsConfirmation && isReschedulable && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={giphyImage} className="w-full rounded-lg" alt="Gif from Giphy" />
|
<img src={giphyImage} className="w-full rounded-lg" alt="Gif from Giphy" />
|
||||||
)}
|
)}
|
||||||
@@ -421,17 +423,19 @@ export default function Success(props: PageProps) {
|
|||||||
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
|
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
|
||||||
isRoundRobin &&
|
isRoundRobin &&
|
||||||
"border-cal-bg dark:border-cal-bg-muted absolute bottom-0 right-0 z-10 h-12 w-12 border-8",
|
"border-cal-bg dark:border-cal-bg-muted absolute bottom-0 right-0 z-10 h-12 w-12 border-8",
|
||||||
!giphyImage && !isCancelled && !needsConfirmation ? "bg-success" : "",
|
!giphyImage && isReschedulable && !needsConfirmation ? "bg-success" : "",
|
||||||
!giphyImage && !isCancelled && needsConfirmation ? "bg-subtle" : "",
|
!giphyImage && isReschedulable && needsConfirmation ? "bg-subtle" : "",
|
||||||
isCancelled ? "bg-error" : ""
|
isCancelled || isBookingInPast ? "bg-error" : ""
|
||||||
)}>
|
)}>
|
||||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
{!giphyImage && !needsConfirmation && isReschedulable && (
|
||||||
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
|
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
)}
|
)}
|
||||||
{needsConfirmation && !isCancelled && (
|
{needsConfirmation && isReschedulable && (
|
||||||
<Icon name="calendar" className="text-emphasis h-5 w-5" />
|
<Icon name="calendar" className="text-emphasis h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
{isCancelled && <Icon name="x" className="h-5 w-5 text-red-600 dark:text-red-200" />}
|
{(isCancelled || isBookingInPast) && (
|
||||||
|
<Icon name="x" className="h-5 w-5 text-red-600 dark:text-red-200" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-8 mt-6 text-center last:mb-0">
|
<div className="mb-8 mt-6 text-center last:mb-0">
|
||||||
@@ -439,7 +443,7 @@ export default function Success(props: PageProps) {
|
|||||||
className="text-emphasis text-2xl font-semibold leading-6"
|
className="text-emphasis text-2xl font-semibold leading-6"
|
||||||
data-testid={isCancelled ? "cancelled-headline" : ""}
|
data-testid={isCancelled ? "cancelled-headline" : ""}
|
||||||
id="modal-headline">
|
id="modal-headline">
|
||||||
{needsConfirmation && !isCancelled
|
{needsConfirmation && isReschedulable
|
||||||
? props.recurringBookings
|
? props.recurringBookings
|
||||||
? t("booking_submitted_recurring")
|
? t("booking_submitted_recurring")
|
||||||
: t("booking_submitted")
|
: t("booking_submitted")
|
||||||
@@ -447,6 +451,8 @@ export default function Success(props: PageProps) {
|
|||||||
? seatReferenceUid
|
? seatReferenceUid
|
||||||
? t("no_longer_attending")
|
? t("no_longer_attending")
|
||||||
: t("event_cancelled")
|
: t("event_cancelled")
|
||||||
|
: isBookingInPast
|
||||||
|
? t("event_expired")
|
||||||
: props.recurringBookings
|
: props.recurringBookings
|
||||||
? t("meeting_is_scheduled_recurring")
|
? t("meeting_is_scheduled_recurring")
|
||||||
: t("meeting_is_scheduled")}
|
: t("meeting_is_scheduled")}
|
||||||
@@ -650,7 +656,7 @@ export default function Success(props: PageProps) {
|
|||||||
)}
|
)}
|
||||||
{!requiresLoginToUpdate &&
|
{!requiresLoginToUpdate &&
|
||||||
(!needsConfirmation || !userIsOwner) &&
|
(!needsConfirmation || !userIsOwner) &&
|
||||||
!isCancelled &&
|
isReschedulable &&
|
||||||
(!isCancellationMode ? (
|
(!isCancellationMode ? (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle mb-8" />
|
<hr className="border-subtle mb-8" />
|
||||||
@@ -708,7 +714,7 @@ export default function Success(props: PageProps) {
|
|||||||
{userIsOwner &&
|
{userIsOwner &&
|
||||||
!needsConfirmation &&
|
!needsConfirmation &&
|
||||||
!isCancellationMode &&
|
!isCancellationMode &&
|
||||||
!isCancelled &&
|
isReschedulable &&
|
||||||
!!calculatedDuration && (
|
!!calculatedDuration && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle mt-8" />
|
<hr className="border-subtle mt-8" />
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const Item = ({
|
|||||||
</small>
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
<Badge variant="gray" className="ml-2">
|
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
|
||||||
{t("readonly")}
|
{t("readonly")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -195,7 +195,7 @@ const Item = ({
|
|||||||
</small>
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
<Badge variant="gray" className="ml-2">
|
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
|
||||||
{t("readonly")}
|
{t("readonly")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -513,7 +513,8 @@ export const EventTypeList = ({
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{!isManagedEventType && !isChildrenManagedEventType && (
|
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||||
|
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@@ -541,25 +542,24 @@ export const EventTypeList = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||||
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
|
{!readOnly && !isChildrenManagedEventType && (
|
||||||
!isChildrenManagedEventType && (
|
<>
|
||||||
<>
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownItem
|
||||||
<DropdownItem
|
color="destructive"
|
||||||
color="destructive"
|
onClick={() => {
|
||||||
onClick={() => {
|
setDeleteDialogOpen(true);
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogTypeId(type.id);
|
||||||
setDeleteDialogTypeId(type.id);
|
setDeleteDialogSchedulingType(type.schedulingType);
|
||||||
setDeleteDialogSchedulingType(type.schedulingType);
|
}}
|
||||||
}}
|
StartIcon="trash"
|
||||||
StartIcon="trash"
|
className="w-full rounded-none">
|
||||||
className="w-full rounded-none">
|
{t("delete")}
|
||||||
{t("delete")}
|
</DropdownItem>
|
||||||
</DropdownItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
@@ -629,7 +629,7 @@ export const EventTypeList = ({
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{!isManagedEventType && !isChildrenManagedEventType && (
|
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={() => openDuplicateModal(type, group)}
|
onClick={() => openDuplicateModal(type, group)}
|
||||||
@@ -640,24 +640,23 @@ export const EventTypeList = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||||
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
|
{!readOnly && !isChildrenManagedEventType && (
|
||||||
!isChildrenManagedEventType && (
|
<>
|
||||||
<>
|
<DropdownMenuItem className="outline-none">
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownItem
|
||||||
<DropdownItem
|
color="destructive"
|
||||||
color="destructive"
|
onClick={() => {
|
||||||
onClick={() => {
|
setDeleteDialogOpen(true);
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogTypeId(type.id);
|
||||||
setDeleteDialogTypeId(type.id);
|
setDeleteDialogSchedulingType(type.schedulingType);
|
||||||
setDeleteDialogSchedulingType(type.schedulingType);
|
}}
|
||||||
}}
|
StartIcon="trash"
|
||||||
StartIcon="trash"
|
className="w-full rounded-none">
|
||||||
className="w-full rounded-none">
|
{t("delete")}
|
||||||
{t("delete")}
|
</DropdownItem>
|
||||||
</DropdownItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{!isManagedEventType && (
|
{!isManagedEventType && (
|
||||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import type { TFunction } from "next-i18next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
|
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -14,6 +15,7 @@ import { z } from "zod";
|
|||||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||||
import { validateCustomEventName } from "@calcom/core/event";
|
import { validateCustomEventName } from "@calcom/core/event";
|
||||||
|
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
|
||||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||||
@@ -24,7 +26,6 @@ import { HttpError } from "@calcom/lib/http-error";
|
|||||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
|
||||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
@@ -144,7 +145,70 @@ const querySchema = z.object({
|
|||||||
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||||
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
||||||
|
|
||||||
const EventTypePage = (props: EventTypeSetupProps) => {
|
export const locationsResolver = (t: TFunction) => {
|
||||||
|
return z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
link: z.string().url().optional(),
|
||||||
|
phone: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
|
.optional(),
|
||||||
|
hostPhoneNumber: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
|
.optional(),
|
||||||
|
displayLocationPublicly: z.boolean().optional(),
|
||||||
|
credentialId: z.number().optional(),
|
||||||
|
teamName: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (val?.link) {
|
||||||
|
const link = val.link;
|
||||||
|
const eventLocationType = getEventLocationType(val.type);
|
||||||
|
if (
|
||||||
|
eventLocationType &&
|
||||||
|
!eventLocationType.default &&
|
||||||
|
eventLocationType.linkType === "static" &&
|
||||||
|
eventLocationType.urlRegExp
|
||||||
|
) {
|
||||||
|
const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success;
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
const sampleUrl = eventLocationType.organizerInputPlaceholder;
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [eventLocationType?.defaultValueVariable ?? "link"],
|
||||||
|
message: t("invalid_url_error_message", {
|
||||||
|
label: eventLocationType.label,
|
||||||
|
sampleUrl: sampleUrl ?? "https://cal.com",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = z.string().url().optional().safeParse(link).success;
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [eventLocationType?.defaultValueVariable ?? "link"],
|
||||||
|
message: `Invalid URL`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional();
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
@@ -326,69 +390,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||||
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||||
bookingFields: eventTypeBookingFields,
|
bookingFields: eventTypeBookingFields,
|
||||||
locations: z
|
locations: locationsResolver(t),
|
||||||
.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.string(),
|
|
||||||
address: z.string().optional(),
|
|
||||||
link: z.string().url().optional(),
|
|
||||||
phone: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
|
||||||
.optional(),
|
|
||||||
hostPhoneNumber: z
|
|
||||||
.string()
|
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
|
||||||
.optional(),
|
|
||||||
displayLocationPublicly: z.boolean().optional(),
|
|
||||||
credentialId: z.number().optional(),
|
|
||||||
teamName: z.string().optional(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
.superRefine((val, ctx) => {
|
|
||||||
if (val?.link) {
|
|
||||||
const link = val.link;
|
|
||||||
const eventLocationType = getEventLocationType(val.type);
|
|
||||||
if (
|
|
||||||
eventLocationType &&
|
|
||||||
!eventLocationType.default &&
|
|
||||||
eventLocationType.linkType === "static" &&
|
|
||||||
eventLocationType.urlRegExp
|
|
||||||
) {
|
|
||||||
const valid = z
|
|
||||||
.string()
|
|
||||||
.regex(new RegExp(eventLocationType.urlRegExp))
|
|
||||||
.safeParse(link).success;
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
const sampleUrl = eventLocationType.organizerInputPlaceholder;
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: [eventLocationType?.defaultValueVariable ?? "link"],
|
|
||||||
message: t("invalid_url_error_message", {
|
|
||||||
label: eventLocationType.label,
|
|
||||||
sampleUrl: sampleUrl ?? "https://cal.com",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = z.string().url().optional().safeParse(link).success;
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: [eventLocationType?.defaultValueVariable ?? "link"],
|
|
||||||
message: `Invalid URL`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
// TODO: Add schema for other fields later.
|
// TODO: Add schema for other fields later.
|
||||||
.passthrough()
|
.passthrough()
|
||||||
@@ -398,40 +400,40 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
formState: { isDirty: isFormDirty, dirtyFields },
|
formState: { isDirty: isFormDirty, dirtyFields },
|
||||||
} = formMethods;
|
} = formMethods;
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const handleRouteChange = (url: string) => {
|
// const handleRouteChange = (url: string) => {
|
||||||
const paths = url.split("/");
|
// const paths = url.split("/");
|
||||||
|
//
|
||||||
// Check if event is managed event type - skip if there is assigned users
|
// // Check if event is managed event type - skip if there is assigned users
|
||||||
const assignedUsers = eventType.children;
|
// const assignedUsers = eventType.children;
|
||||||
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
// const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||||
if (eventType.assignAllTeamMembers) {
|
// if (eventType.assignAllTeamMembers) {
|
||||||
return;
|
// return;
|
||||||
} else if (isManagedEventType && assignedUsers.length > 0) {
|
// } else if (isManagedEventType && assignedUsers.length > 0) {
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
const hosts = eventType.hosts;
|
// const hosts = eventType.hosts;
|
||||||
if (
|
// if (
|
||||||
!leaveWithoutAssigningHosts.current &&
|
// !leaveWithoutAssigningHosts.current &&
|
||||||
!!team &&
|
// !!team &&
|
||||||
(hosts.length === 0 || assignedUsers.length === 0) &&
|
// (hosts.length === 0 || assignedUsers.length === 0) &&
|
||||||
(url === "/event-types" || paths[1] !== "event-types")
|
// (url === "/event-types" || paths[1] !== "event-types")
|
||||||
) {
|
// ) {
|
||||||
setIsOpenAssignmentWarnDialog(true);
|
// setIsOpenAssignmentWarnDialog(true);
|
||||||
setPendingRoute(url);
|
// setPendingRoute(url);
|
||||||
router.events.emit(
|
// router.events.emit(
|
||||||
"routeChangeError",
|
// "routeChangeError",
|
||||||
new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
// new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
||||||
);
|
// );
|
||||||
throw "Aborted";
|
// throw "Aborted";
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
router.events.on("routeChangeStart", handleRouteChange);
|
// router.events.on("routeChangeStart", handleRouteChange);
|
||||||
return () => {
|
// return () => {
|
||||||
router.events.off("routeChangeStart", handleRouteChange);
|
// router.events.off("routeChangeStart", handleRouteChange);
|
||||||
};
|
// };
|
||||||
}, [router]);
|
// }, [router]);
|
||||||
|
|
||||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||||
const availability = formMethods.watch("availability");
|
const availability = formMethods.watch("availability");
|
||||||
@@ -464,11 +466,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
|
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
|
||||||
recurring: <EventRecurringTab eventType={eventType} />,
|
recurring: <EventRecurringTab eventType={eventType} />,
|
||||||
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
|
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
|
||||||
workflows: (
|
workflows: props.allActiveWorkflows ? (
|
||||||
<EventWorkflowsTab
|
<EventWorkflowsTab eventType={eventType} workflows={props.allActiveWorkflows} />
|
||||||
eventType={eventType}
|
) : (
|
||||||
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
|
<></>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
webhooks: <EventWebhooksTab eventType={eventType} />,
|
webhooks: <EventWebhooksTab eventType={eventType} />,
|
||||||
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
|
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
|
||||||
@@ -705,7 +706,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
<EventTypeSingleLayout
|
<EventTypeSingleLayout
|
||||||
enabledAppsNumber={numberOfActiveApps}
|
enabledAppsNumber={numberOfActiveApps}
|
||||||
installedAppsNumber={eventTypeApps?.items.length || 0}
|
installedAppsNumber={eventTypeApps?.items.length || 0}
|
||||||
enabledWorkflowsNumber={eventType.workflows.length}
|
enabledWorkflowsNumber={props.allActiveWorkflows ? props.allActiveWorkflows.length : 0}
|
||||||
eventType={eventType}
|
eventType={eventType}
|
||||||
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
|
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
|
||||||
team={team}
|
team={team}
|
||||||
@@ -836,13 +837,13 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AssignmentWarningDialog
|
{/*<AssignmentWarningDialog*/}
|
||||||
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
|
{/* isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}*/}
|
||||||
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
|
{/* setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}*/}
|
||||||
pendingRoute={pendingRoute}
|
{/* pendingRoute={pendingRoute}*/}
|
||||||
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
|
{/* leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}*/}
|
||||||
id={eventType.id}
|
{/* id={eventType.id}*/}
|
||||||
/>
|
{/*/>*/}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -853,7 +854,25 @@ const EventTypePageWrapper: React.FC<PageProps> & {
|
|||||||
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
|
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
return <EventTypePage {...(data as EventTypeSetupProps)} />;
|
|
||||||
|
const eventType = data.eventType;
|
||||||
|
|
||||||
|
const { data: workflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({
|
||||||
|
eventType: {
|
||||||
|
workflows: eventType.workflows,
|
||||||
|
teamId: eventType.teamId,
|
||||||
|
userId: eventType.userId,
|
||||||
|
parent: eventType.parent,
|
||||||
|
metadata: eventType.metadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const propsData = {
|
||||||
|
...(data as EventTypeSetupProps),
|
||||||
|
allActiveWorkflows: workflows,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <EventTypePage {...propsData} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EventTypePageWrapper;
|
export default EventTypePageWrapper;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
import { generateGuestMeetingTokenFromOwnerMeetingToken } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
import {
|
||||||
|
generateGuestMeetingTokenFromOwnerMeetingToken,
|
||||||
|
setEnableRecordingUIForOrganizer,
|
||||||
|
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
||||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
@@ -40,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
},
|
},
|
||||||
references: {
|
references: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
uid: true,
|
uid: true,
|
||||||
type: true,
|
type: true,
|
||||||
meetingUrl: true,
|
meetingUrl: true,
|
||||||
@@ -104,17 +108,30 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const session = await getServerSession({ req });
|
const session = await getServerSession({ req });
|
||||||
|
|
||||||
|
const oldVideoReference = getCalVideoReference(bookingObj.references);
|
||||||
|
|
||||||
// set meetingPassword for guests
|
// set meetingPassword for guests
|
||||||
if (session?.user.id !== bookingObj.user?.id) {
|
if (session?.user.id !== bookingObj.user?.id) {
|
||||||
const videoReference = getCalVideoReference(bookingObj.references);
|
|
||||||
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
|
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
|
||||||
videoReference.meetingPassword
|
oldVideoReference.meetingPassword
|
||||||
);
|
);
|
||||||
|
|
||||||
bookingObj.references.forEach((bookRef) => {
|
bookingObj.references.forEach((bookRef) => {
|
||||||
bookRef.meetingPassword = guestMeetingPassword;
|
bookRef.meetingPassword = guestMeetingPassword;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Only for backward compatibility for organizer
|
||||||
|
else {
|
||||||
|
const meetingPassword = await setEnableRecordingUIForOrganizer(
|
||||||
|
oldVideoReference.id,
|
||||||
|
oldVideoReference.meetingPassword
|
||||||
|
);
|
||||||
|
if (!!meetingPassword) {
|
||||||
|
bookingObj.references.forEach((bookRef) => {
|
||||||
|
bookRef.meetingPassword = meetingPassword;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const videoReference = getCalVideoReference(bookingObj.references);
|
const videoReference = getCalVideoReference(bookingObj.references);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ if (!process.env.NEXT_PUBLIC_API_V2_URL) {
|
|||||||
console.error("Please set NEXT_PUBLIC_API_V2_URL");
|
console.error("Please set NEXT_PUBLIC_API_V2_URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getHttpsUrl = (url) => {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.startsWith("http://")) {
|
||||||
|
return url.replace("http://", "https://");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.argv.includes("--experimental-https")) {
|
||||||
|
process.env.NEXT_PUBLIC_WEBAPP_URL = getHttpsUrl(process.env.NEXT_PUBLIC_WEBAPP_URL);
|
||||||
|
process.env.NEXTAUTH_URL = getHttpsUrl(process.env.NEXTAUTH_URL);
|
||||||
|
process.env.NEXT_PUBLIC_EMBED_LIB_URL = getHttpsUrl(process.env.NEXT_PUBLIC_EMBED_LIB_URL);
|
||||||
|
}
|
||||||
|
|
||||||
const validJson = (jsonString) => {
|
const validJson = (jsonString) => {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(jsonString);
|
const o = JSON.parse(jsonString);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "4.2.3",
|
"version": "4.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
|
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
|
||||||
"dx": "yarn dev",
|
"dx": "yarn dev",
|
||||||
"test-codegen": "yarn playwright codegen http://localhost:3000",
|
"test-codegen": "yarn playwright codegen http://localhost:3000",
|
||||||
"type-check": "tsc --pretty --noEmit",
|
"type-check": "tsc --pretty --noEmit",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
@@ -66,7 +68,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
id: true,
|
id: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
|
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
|
||||||
|
await stripe.customers.update(userMetadataParsed.stripeCustomerId, {
|
||||||
|
email: updatedEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The user is trying to update the email to an already existing unverified secondary email of his
|
// The user is trying to update the email to an already existing unverified secondary email of his
|
||||||
// so we swap the emails and its verified status
|
// so we swap the emails and its verified status
|
||||||
if (existingSecondaryUser?.userId === user.id) {
|
if (existingSecondaryUser?.userId === user.id) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
|
|
||||||
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
|
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||||
@@ -62,7 +61,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
|
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
|
||||||
if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` });
|
if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` });
|
||||||
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
|
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
|
||||||
let redirectUrl = "/apps/installed";
|
|
||||||
if (typeof handler === "undefined")
|
if (typeof handler === "undefined")
|
||||||
throw new HttpError({ statusCode: 404, message: `API handler not found` });
|
throw new HttpError({ statusCode: 404, message: `API handler not found` });
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
} else {
|
} else {
|
||||||
await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler });
|
await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler });
|
||||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
const redirectUrl = handler.redirect?.url ?? undefined;
|
||||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||||
}
|
}
|
||||||
if (!res.writableEnded) return res.status(200);
|
if (!res.writableEnded) return res.status(200);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { WebhookTriggerEvents } from "@prisma/client";
|
|
||||||
import { createHmac } from "crypto";
|
import { createHmac } from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
|
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
|
||||||
import {
|
import {
|
||||||
getDownloadLinkOfCalVideoByRecordingId,
|
getDownloadLinkOfCalVideoByRecordingId,
|
||||||
submitBatchProcessorTranscriptionJob,
|
submitBatchProcessorTranscriptionJob,
|
||||||
@@ -10,210 +9,48 @@ import {
|
|||||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
|
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
|
||||||
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
|
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
|
||||||
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
|
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
|
||||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
|
||||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
|
||||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
|
||||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { defaultHandler } from "@calcom/lib/server";
|
import { defaultHandler } from "@calcom/lib/server";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import prisma from "@calcom/prisma";
|
||||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
|
||||||
|
import { getCalendarEvent } from "@calcom/web/lib/daily-webhook/getCalendarEvent";
|
||||||
|
import {
|
||||||
|
meetingEndedSchema,
|
||||||
|
recordingReadySchema,
|
||||||
|
batchProcessorJobFinishedSchema,
|
||||||
|
downloadLinkSchema,
|
||||||
|
testRequestSchema,
|
||||||
|
} from "@calcom/web/lib/daily-webhook/schema";
|
||||||
|
import {
|
||||||
|
triggerRecordingReadyWebhook,
|
||||||
|
triggerTranscriptionGeneratedWebhook,
|
||||||
|
} from "@calcom/web/lib/daily-webhook/triggerWebhooks";
|
||||||
|
|
||||||
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
|
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
|
||||||
|
|
||||||
const commonSchema = z
|
const computeSignature = (
|
||||||
.object({
|
hmacSecret: string,
|
||||||
version: z.string(),
|
reqBody: NextApiRequest["body"],
|
||||||
type: z.string(),
|
webhookTimestampHeader: string | string[] | undefined
|
||||||
id: z.string(),
|
) => {
|
||||||
event_ts: z.number().optional(),
|
const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`;
|
||||||
})
|
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
|
||||||
.passthrough();
|
const hmac = createHmac("sha256", base64DecodedSecret);
|
||||||
|
const computed_signature = hmac.update(signature).digest("base64");
|
||||||
const meetingEndedSchema = commonSchema.extend({
|
return computed_signature;
|
||||||
payload: z
|
|
||||||
.object({
|
|
||||||
meeting_id: z.string(),
|
|
||||||
end_ts: z.number().optional(),
|
|
||||||
room: z.string(),
|
|
||||||
start_ts: z.number().optional(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const recordingReadySchema = commonSchema.extend({
|
|
||||||
payload: z.object({
|
|
||||||
recording_id: z.string(),
|
|
||||||
end_ts: z.number().optional(),
|
|
||||||
room_name: z.string(),
|
|
||||||
start_ts: z.number().optional(),
|
|
||||||
status: z.string(),
|
|
||||||
|
|
||||||
max_participants: z.number().optional(),
|
|
||||||
duration: z.number().optional(),
|
|
||||||
s3_key: z.string().optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const downloadLinkSchema = z.object({
|
|
||||||
download_link: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const triggerWebhook = async ({
|
|
||||||
evt,
|
|
||||||
downloadLink,
|
|
||||||
booking,
|
|
||||||
}: {
|
|
||||||
evt: CalendarEvent;
|
|
||||||
downloadLink: string;
|
|
||||||
booking: {
|
|
||||||
userId: number | undefined;
|
|
||||||
eventTypeId: number | null;
|
|
||||||
eventTypeParentId: number | null | undefined;
|
|
||||||
teamId?: number | null;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
|
|
||||||
|
|
||||||
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
|
||||||
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
|
|
||||||
|
|
||||||
const organizerUserId = triggerForUser ? booking.userId : null;
|
|
||||||
|
|
||||||
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
|
|
||||||
|
|
||||||
const subscriberOptions = {
|
|
||||||
userId: organizerUserId,
|
|
||||||
eventTypeId: booking.eventTypeId,
|
|
||||||
triggerEvent: eventTrigger,
|
|
||||||
teamId: booking.teamId,
|
|
||||||
orgId,
|
|
||||||
};
|
|
||||||
const webhooks = await getWebhooks(subscriberOptions);
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"Webhooks:",
|
|
||||||
safeStringify({
|
|
||||||
webhooks,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const promises = webhooks.map((webhook) =>
|
|
||||||
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
|
|
||||||
...evt,
|
|
||||||
downloadLink,
|
|
||||||
}).catch((e) => {
|
|
||||||
log.error(
|
|
||||||
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
|
|
||||||
safeStringify(e)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await Promise.all(promises);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBooking = async (bookingId: number) => {
|
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
|
||||||
const booking = await prisma.booking.findUniqueOrThrow({
|
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
|
||||||
where: {
|
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
||||||
id: bookingId,
|
const downloadLink = downloadLinkResponse.download_link;
|
||||||
},
|
return downloadLink;
|
||||||
select: {
|
|
||||||
...bookingMinimalSelect,
|
|
||||||
uid: true,
|
|
||||||
location: true,
|
|
||||||
isRecorded: true,
|
|
||||||
eventTypeId: true,
|
|
||||||
eventType: {
|
|
||||||
select: {
|
|
||||||
teamId: true,
|
|
||||||
parentId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
timeZone: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
locale: true,
|
|
||||||
destinationCalendar: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
log.error(
|
|
||||||
"Couldn't find Booking Id:",
|
|
||||||
safeStringify({
|
|
||||||
bookingId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return booking;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBookingReference = async (roomName: string) => {
|
|
||||||
const bookingReference = await prisma.bookingReference.findFirst({
|
|
||||||
where: { type: "daily_video", uid: roomName, meetingId: roomName },
|
|
||||||
select: { bookingId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!bookingReference) {
|
|
||||||
log.error(
|
|
||||||
"bookingReference not found error:",
|
|
||||||
safeStringify({
|
|
||||||
bookingReference,
|
|
||||||
roomName,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bookingReference;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCalendarEvent = async (booking: Awaited<ReturnType<typeof getBooking>>) => {
|
|
||||||
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
|
|
||||||
|
|
||||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
|
||||||
return {
|
|
||||||
id: attendee.id,
|
|
||||||
name: attendee.name,
|
|
||||||
email: attendee.email,
|
|
||||||
timeZone: attendee.timeZone,
|
|
||||||
language: {
|
|
||||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
|
||||||
locale: attendee.locale ?? "en",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const attendeesList = await Promise.all(attendeesListPromises);
|
|
||||||
const evt: CalendarEvent = {
|
|
||||||
type: booking.title,
|
|
||||||
title: booking.title,
|
|
||||||
description: booking.description || undefined,
|
|
||||||
startTime: booking.startTime.toISOString(),
|
|
||||||
endTime: booking.endTime.toISOString(),
|
|
||||||
organizer: {
|
|
||||||
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
|
||||||
name: booking.user?.name || "Nameless",
|
|
||||||
timeZone: booking.user?.timeZone || "Europe/London",
|
|
||||||
language: { translate: t, locale: booking?.user?.locale ?? "en" },
|
|
||||||
},
|
|
||||||
attendees: attendeesList,
|
|
||||||
uid: booking.uid,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Promise.resolve(evt);
|
|
||||||
};
|
|
||||||
|
|
||||||
const testRequestSchema = z.object({
|
|
||||||
test: z.enum(["test"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
||||||
return res.status(405).json({ message: "No SendGrid API key or email" });
|
return res.status(405).json({ message: "No SendGrid API key or email" });
|
||||||
@@ -223,18 +60,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(200).json({ message: "Test request successful" });
|
return res.status(200).json({ message: "Test request successful" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
|
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
|
||||||
if (!hmacSecret) {
|
|
||||||
return res.status(405).json({ message: "No Daily Webhook Secret" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`;
|
if (!testMode) {
|
||||||
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
|
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
|
||||||
const hmac = createHmac("sha256", base64DecodedSecret);
|
if (!hmacSecret) {
|
||||||
const computed_signature = hmac.update(signature).digest("base64");
|
return res.status(405).json({ message: "No Daily Webhook Secret" });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.headers["x-webhook-signature"] !== computed_signature) {
|
const computed_signature = computeSignature(hmacSecret, req.body, req.headers["x-webhook-timestamp"]);
|
||||||
return res.status(403).json({ message: "Signature does not match" });
|
|
||||||
|
if (req.headers["x-webhook-signature"] !== computed_signature) {
|
||||||
|
return res.status(403).json({ message: "Signature does not match" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -261,19 +99,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
message: "Recording not finished",
|
message: "Recording not finished",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookingReference = await getBookingReference(room_name);
|
const bookingReference = await getBookingReference(room_name);
|
||||||
|
const booking = await getBooking(bookingReference.bookingId as number);
|
||||||
if (!bookingReference || !bookingReference.bookingId) {
|
|
||||||
return res.status(200).send({ message: "Booking reference not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const booking = await getBooking(bookingReference.bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
return res.status(404).send({
|
|
||||||
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const evt = await getCalendarEvent(booking);
|
const evt = await getCalendarEvent(booking);
|
||||||
|
|
||||||
@@ -286,9 +114,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
|
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
|
||||||
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
|
||||||
const downloadLink = downloadLinkResponse.download_link;
|
|
||||||
|
|
||||||
const teamId = await getTeamIdFromEventType({
|
const teamId = await getTeamIdFromEventType({
|
||||||
eventType: {
|
eventType: {
|
||||||
@@ -297,7 +123,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerRecordingReadyWebhook({
|
||||||
evt,
|
evt,
|
||||||
downloadLink,
|
downloadLink,
|
||||||
booking: {
|
booking: {
|
||||||
@@ -317,6 +143,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// send emails to all attendees only when user has team plan
|
// send emails to all attendees only when user has team plan
|
||||||
await sendDailyVideoRecordingEmails(evt, downloadLink);
|
await sendDailyVideoRecordingEmails(evt, downloadLink);
|
||||||
|
|
||||||
return res.status(200).json({ message: "Success" });
|
return res.status(200).json({ message: "Success" });
|
||||||
} else if (req.body.type === "meeting.ended") {
|
} else if (req.body.type === "meeting.ended") {
|
||||||
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
|
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
|
||||||
@@ -325,20 +152,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
message: "Invalid Payload",
|
message: "Invalid Payload",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { room } = meetingEndedResponse.data.payload;
|
const { room } = meetingEndedResponse.data.payload;
|
||||||
|
|
||||||
const bookingReference = await getBookingReference(room);
|
const bookingReference = await getBookingReference(room);
|
||||||
if (!bookingReference || !bookingReference.bookingId) {
|
const booking = await getBooking(bookingReference.bookingId as number);
|
||||||
return res.status(200).send({ message: "Booking reference not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const booking = await getBooking(bookingReference.bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
return res.status(404).send({
|
|
||||||
message: `Booking of room_name ${room} does not exist or does not contain daily video as location`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
|
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
|
||||||
|
|
||||||
@@ -349,10 +167,62 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
await sendDailyVideoTranscriptEmails(evt, transcripts);
|
await sendDailyVideoTranscriptEmails(evt, transcripts);
|
||||||
|
|
||||||
return res.status(200).json({ message: "Success" });
|
return res.status(200).json({ message: "Success" });
|
||||||
|
} else if (req.body?.type === "batch-processor.job-finished") {
|
||||||
|
const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!batchProcessorJobFinishedResponse.success) {
|
||||||
|
return res.status(400).send({
|
||||||
|
message: "Invalid Payload",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, input } = batchProcessorJobFinishedResponse.data.payload;
|
||||||
|
const roomName = await getRoomNameFromRecordingId(input.recordingId);
|
||||||
|
|
||||||
|
const bookingReference = await getBookingReference(roomName);
|
||||||
|
|
||||||
|
const booking = await getBooking(bookingReference.bookingId as number);
|
||||||
|
|
||||||
|
const teamId = await getTeamIdFromEventType({
|
||||||
|
eventType: {
|
||||||
|
team: { id: booking?.eventType?.teamId ?? null },
|
||||||
|
parentId: booking?.eventType?.parentId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const evt = await getCalendarEvent(booking);
|
||||||
|
|
||||||
|
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
|
||||||
|
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
|
||||||
|
|
||||||
|
await triggerTranscriptionGeneratedWebhook({
|
||||||
|
evt,
|
||||||
|
downloadLinks: {
|
||||||
|
transcription: batchProcessorJobAccessLink.transcription,
|
||||||
|
recording,
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
userId: booking?.user?.id,
|
||||||
|
eventTypeId: booking.eventTypeId,
|
||||||
|
eventTypeParentId: booking.eventType?.parentId,
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ message: "Success" });
|
||||||
|
} else {
|
||||||
|
log.error("Invalid type in /recorded-daily-video", req.body);
|
||||||
|
|
||||||
|
return res.status(200).json({ message: "Invalid type in /recorded-daily-video" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Error in /recorded-daily-video", err);
|
log.error("Error in /recorded-daily-video", err);
|
||||||
return res.status(500).json({ message: "something went wrong" });
|
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return res.status(err.statusCode).json({ message: err.message });
|
||||||
|
} else {
|
||||||
|
return res.status(500).json({ message: "something went wrong" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
|||||||
slug={data.slug}
|
slug={data.slug}
|
||||||
variant={data.variant}
|
variant={data.variant}
|
||||||
type={data.type}
|
type={data.type}
|
||||||
dirName={data.dirName}
|
|
||||||
logo={data.logo}
|
logo={data.logo}
|
||||||
categories={data.categories ?? [data.category]}
|
categories={data.categories ?? [data.category]}
|
||||||
author={data.publisher}
|
author={data.publisher}
|
||||||
|
|||||||
@@ -8,17 +8,22 @@ import { Toaster } from "react-hot-toast";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||||
|
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||||
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
|
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
|
||||||
|
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
|
||||||
|
import type { LocationObject } from "@calcom/core/location";
|
||||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||||
import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl";
|
|
||||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { AppMeta } from "@calcom/types/App";
|
import type { AppMeta } from "@calcom/types/App";
|
||||||
@@ -34,8 +39,13 @@ import { EventTypesStepCard } from "@components/apps/installation/EventTypesStep
|
|||||||
import { StepHeader } from "@components/apps/installation/StepHeader";
|
import { StepHeader } from "@components/apps/installation/StepHeader";
|
||||||
|
|
||||||
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
|
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
|
||||||
Pick<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
|
Pick<
|
||||||
|
EventTypeModel,
|
||||||
|
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
|
||||||
|
> & {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
locations: LocationFormValues["locations"];
|
||||||
|
bookingFields?: LocationFormValues["bookingFields"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TEventTypesForm = {
|
export type TEventTypesForm = {
|
||||||
@@ -47,7 +57,6 @@ const STEPS = [
|
|||||||
AppOnboardingSteps.EVENT_TYPES_STEP,
|
AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||||
AppOnboardingSteps.CONFIGURE_STEP,
|
AppOnboardingSteps.CONFIGURE_STEP,
|
||||||
] as const;
|
] as const;
|
||||||
const MAX_NUMBER_OF_STEPS = STEPS.length;
|
|
||||||
|
|
||||||
type StepType = (typeof STEPS)[number];
|
type StepType = (typeof STEPS)[number];
|
||||||
|
|
||||||
@@ -63,11 +72,21 @@ type StepObj = Record<
|
|||||||
type OnboardingPageProps = {
|
type OnboardingPageProps = {
|
||||||
appMetadata: AppMeta;
|
appMetadata: AppMeta;
|
||||||
step: StepType;
|
step: StepType;
|
||||||
teams: TeamsProp;
|
teams?: TeamsProp;
|
||||||
personalAccount: PersonalAccountProps;
|
personalAccount: PersonalAccountProps;
|
||||||
eventTypes?: TEventType[];
|
eventTypes?: TEventType[];
|
||||||
userName: string;
|
userName: string;
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
|
showEventTypesStep: boolean;
|
||||||
|
isConferencing: boolean;
|
||||||
|
installableOnTeams: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TUpdateObject = {
|
||||||
|
id: number;
|
||||||
|
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
|
||||||
|
bookingFields?: z.infer<typeof eventTypeBookingFields>;
|
||||||
|
locations?: LocationObject[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const OnboardingPage = ({
|
const OnboardingPage = ({
|
||||||
@@ -78,6 +97,9 @@ const OnboardingPage = ({
|
|||||||
eventTypes,
|
eventTypes,
|
||||||
userName,
|
userName,
|
||||||
credentialId,
|
credentialId,
|
||||||
|
showEventTypesStep,
|
||||||
|
isConferencing,
|
||||||
|
installableOnTeams,
|
||||||
}: OnboardingPageProps) => {
|
}: OnboardingPageProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -92,16 +114,15 @@ const OnboardingPage = ({
|
|||||||
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
|
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
|
||||||
getTitle: () => `${t("select_event_types_header")}`,
|
getTitle: () => `${t("select_event_types_header")}`,
|
||||||
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
|
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
|
||||||
stepNumber: 2,
|
stepNumber: installableOnTeams ? 2 : 1,
|
||||||
},
|
},
|
||||||
[AppOnboardingSteps.CONFIGURE_STEP]: {
|
[AppOnboardingSteps.CONFIGURE_STEP]: {
|
||||||
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
|
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
|
||||||
getDescription: () => `${t("configure_app_description")}`,
|
getDescription: () => `${t("configure_app_description")}`,
|
||||||
stepNumber: 3,
|
stepNumber: installableOnTeams ? 3 : 2,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
const [configureStep, setConfigureStep] = useState(false);
|
const [configureStep, setConfigureStep] = useState(false);
|
||||||
const [isSelectingAccount, setIsSelectingAccount] = useState(false);
|
|
||||||
|
|
||||||
const currentStep: AppOnboardingSteps = useMemo(() => {
|
const currentStep: AppOnboardingSteps = useMemo(() => {
|
||||||
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
|
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
|
||||||
@@ -111,6 +132,13 @@ const OnboardingPage = ({
|
|||||||
}, [step, configureStep]);
|
}, [step, configureStep]);
|
||||||
const stepObj = STEPS_MAP[currentStep];
|
const stepObj = STEPS_MAP[currentStep];
|
||||||
|
|
||||||
|
const maxSteps = useMemo(() => {
|
||||||
|
if (!showEventTypesStep) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return installableOnTeams ? STEPS.length : STEPS.length - 1;
|
||||||
|
}, [showEventTypesStep, installableOnTeams]);
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
const formPortalRef = useRef<HTMLDivElement>(null);
|
const formPortalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -120,9 +148,19 @@ const OnboardingPage = ({
|
|||||||
eventTypes,
|
eventTypes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const mutation = useAddAppMutation(null, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data?.setupPending) return;
|
||||||
|
showToast(t("app_successfully_installed"), "success");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventTypes && formMethods.setValue("eventTypes", eventTypes);
|
eventTypes && formMethods.setValue("eventTypes", eventTypes);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [eventTypes]);
|
}, [eventTypes]);
|
||||||
|
|
||||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||||
@@ -156,47 +194,22 @@ const OnboardingPage = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSelectAccount = async (teamId?: number) => {
|
const handleSelectAccount = async (teamId?: number) => {
|
||||||
try {
|
mutation.mutate({
|
||||||
setIsSelectingAccount(true);
|
type: appMetadata.type,
|
||||||
if (appMetadata.isOAuth) {
|
variant: appMetadata.variant,
|
||||||
const state = JSON.stringify({
|
slug: appMetadata.slug,
|
||||||
appOnboardingRedirectUrl: getAppOnboardingRedirectUrl(appMetadata.slug, teamId),
|
...(teamId && { teamId }),
|
||||||
teamId,
|
// for oAuth apps
|
||||||
});
|
...(showEventTypesStep && {
|
||||||
|
returnTo:
|
||||||
const res = await fetch(
|
WEBAPP_URL +
|
||||||
`/api/integrations/${
|
|
||||||
appMetadata.slug == "stripe" ? "stripepayment" : appMetadata.slug
|
|
||||||
}/add?state=${state}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const oAuthUrl = (await res.json())?.url;
|
|
||||||
router.push(oAuthUrl);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
await fetch(`/api/integrations/${appMetadata.slug}/add${teamId ? `?teamId=${teamId}` : ""}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
router.push(
|
|
||||||
getAppOnboardingUrl({
|
getAppOnboardingUrl({
|
||||||
slug: appMetadata.slug,
|
slug: appMetadata.slug,
|
||||||
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
|
||||||
teamId,
|
teamId,
|
||||||
})
|
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||||
);
|
}),
|
||||||
}
|
}),
|
||||||
} catch (error) {
|
});
|
||||||
setIsSelectingAccount(false);
|
|
||||||
router.push(`/apps`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetUpLater = () => {
|
const handleSetUpLater = () => {
|
||||||
@@ -233,10 +246,21 @@ const OnboardingPage = ({
|
|||||||
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
|
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
|
||||||
throw new Error(t("seats_and_no_show_fee_error"));
|
throw new Error(t("seats_and_no_show_fee_error"));
|
||||||
}
|
}
|
||||||
return updateMutation.mutateAsync({
|
let updateObject: TUpdateObject = { id: value.id };
|
||||||
id: value.id,
|
if (isConferencing) {
|
||||||
metadata: value.metadata,
|
updateObject = {
|
||||||
});
|
...updateObject,
|
||||||
|
locations: value.locations,
|
||||||
|
bookingFields: value.bookingFields ? value.bookingFields : undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
updateObject = {
|
||||||
|
...updateObject,
|
||||||
|
metadata: value.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateMutation.mutateAsync(updateObject);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await Promise.all(mutationPromises);
|
await Promise.all(mutationPromises);
|
||||||
@@ -248,14 +272,15 @@ const OnboardingPage = ({
|
|||||||
<StepHeader
|
<StepHeader
|
||||||
title={stepObj.getTitle(appMetadata.name)}
|
title={stepObj.getTitle(appMetadata.name)}
|
||||||
subtitle={stepObj.getDescription(appMetadata.name)}>
|
subtitle={stepObj.getDescription(appMetadata.name)}>
|
||||||
<Steps maxSteps={MAX_NUMBER_OF_STEPS} currentStep={stepObj.stepNumber} disableNavigation />
|
<Steps maxSteps={maxSteps} currentStep={stepObj.stepNumber} disableNavigation />
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
|
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
|
||||||
<AccountsStepCard
|
<AccountsStepCard
|
||||||
teams={teams}
|
teams={teams}
|
||||||
personalAccount={personalAccount}
|
personalAccount={personalAccount}
|
||||||
onSelect={handleSelectAccount}
|
onSelect={handleSelectAccount}
|
||||||
loading={isSelectingAccount}
|
loading={mutation.isPending}
|
||||||
|
installableOnTeams={installableOnTeams}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
|
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
|
||||||
@@ -278,6 +303,7 @@ const OnboardingPage = ({
|
|||||||
setConfigureStep={setConfigureStep}
|
setConfigureStep={setConfigureStep}
|
||||||
eventTypes={eventTypes}
|
eventTypes={eventTypes}
|
||||||
handleSetUpLater={handleSetUpLater}
|
handleSetUpLater={handleSetUpLater}
|
||||||
|
isConferencing={isConferencing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
@@ -385,19 +411,31 @@ const getEventTypes = async (userId: number, teamId?: number) => {
|
|||||||
users: { select: { username: true } },
|
users: { select: { username: true } },
|
||||||
seatsPerTimeSlot: true,
|
seatsPerTimeSlot: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
locations: true,
|
||||||
|
userId: true,
|
||||||
|
destinationCalendar: true,
|
||||||
|
bookingFields: true,
|
||||||
},
|
},
|
||||||
where: teamId ? { teamId } : { userId, teamId: null },
|
/**
|
||||||
|
* filter out managed events for now
|
||||||
|
* @todo: can install apps to managed event types
|
||||||
|
*/
|
||||||
|
where: teamId ? { teamId } : { userId, parent: null, teamId: null },
|
||||||
})
|
})
|
||||||
).sort((eventTypeA, eventTypeB) => {
|
).sort((eventTypeA, eventTypeB) => {
|
||||||
return eventTypeB.position - eventTypeA.position;
|
return eventTypeB.position - eventTypeA.position;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (eventTypes.length === 0) {
|
if (eventTypes.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return eventTypes.map((item) => ({
|
return eventTypes.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
|
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
locations: item.locations as unknown as LocationObject[],
|
||||||
|
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -434,12 +472,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const locale = await getLocale(context.req);
|
const locale = await getLocale(context.req);
|
||||||
const app = await getAppBySlug(parsedAppSlug);
|
const app = await getAppBySlug(parsedAppSlug);
|
||||||
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
|
||||||
const hasEventTypes = appMetadata?.extendsFeature === "EventType";
|
const extendsEventType = appMetadata?.extendsFeature === "EventType";
|
||||||
|
|
||||||
|
const isConferencing = isConferencingApp(appMetadata.categories);
|
||||||
|
const showEventTypesStep = extendsEventType || isConferencing;
|
||||||
|
console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep);
|
||||||
|
|
||||||
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
|
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
|
||||||
if (!hasEventTypes) {
|
|
||||||
throw new Error(ERROR_MESSAGES.appNotExtendsEventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
|
|
||||||
@@ -460,7 +499,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
|
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
|
||||||
|
if (!showEventTypesStep) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
|
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
|
||||||
|
if (isConferencing) {
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
eventTypeId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let index = 0; index < eventTypes.length; index++) {
|
||||||
|
let eventType = eventTypes[index];
|
||||||
|
if (!eventType.destinationCalendar) {
|
||||||
|
eventType = { ...eventType, destinationCalendar };
|
||||||
|
}
|
||||||
|
eventTypes[index] = eventType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (eventTypes.length === 0) {
|
if (eventTypes.length === 0) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
@@ -498,6 +561,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
...(await serverSideTranslations(locale, ["common"])),
|
...(await serverSideTranslations(locale, ["common"])),
|
||||||
app,
|
app,
|
||||||
appMetadata,
|
appMetadata,
|
||||||
|
showEventTypesStep,
|
||||||
step: parsedStepParam,
|
step: parsedStepParam,
|
||||||
teams: teamsWithIsAppInstalled,
|
teams: teamsWithIsAppInstalled,
|
||||||
personalAccount,
|
personalAccount,
|
||||||
@@ -505,9 +569,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
teamId: parsedTeamIdParam ?? null,
|
teamId: parsedTeamIdParam ?? null,
|
||||||
userName: user.username,
|
userName: user.username,
|
||||||
credentialId,
|
credentialId,
|
||||||
|
isConferencing,
|
||||||
|
// conferencing apps dont support team install
|
||||||
|
installableOnTeams: !isConferencing,
|
||||||
} as OnboardingPageProps,
|
} as OnboardingPageProps,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log("eerrerrerrerrerrerrerrerrrr: ", err);
|
||||||
if (err instanceof z.ZodError) {
|
if (err instanceof z.ZodError) {
|
||||||
return { redirect: { permanent: false, destination: "/apps" } };
|
return { redirect: { permanent: false, destination: "/apps" } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,15 +123,8 @@ function UsernameField({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
checkUsername();
|
checkUsername();
|
||||||
}, [
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
debouncedUsername,
|
}, [debouncedUsername, disabled, orgSlug, formState.isSubmitting, formState.isSubmitSuccessful]);
|
||||||
setPremium,
|
|
||||||
disabled,
|
|
||||||
orgSlug,
|
|
||||||
setUsernameTaken,
|
|
||||||
formState.isSubmitting,
|
|
||||||
formState.isSubmitSuccessful,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,39 +5,41 @@ const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"];
|
|||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
test.afterEach(({ users }) => users.deleteAll());
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
test.describe("Check analytics Apps ", () => {
|
test.describe("check analytics Apps", () => {
|
||||||
test("Check analytics Apps by skipping the configure step", async ({ appsPage, page, users }) => {
|
test.describe("check analytics apps by skipping the configure step", () => {
|
||||||
const user = await users.create();
|
ALL_APPS.forEach((app) => {
|
||||||
await user.apiLogin();
|
test(`check analytics app: ${app} by skipping the configure step`, async ({
|
||||||
await page.goto("/apps/");
|
appsPage,
|
||||||
await appsPage.goToAppsCategory("analytics");
|
page,
|
||||||
for (const app of ALL_APPS) {
|
users,
|
||||||
await appsPage.installAppSkipConfigure(app);
|
}) => {
|
||||||
await appsPage.goBackToAppsPage();
|
const user = await users.create();
|
||||||
}
|
await user.apiLogin();
|
||||||
await page.goto("/event-types");
|
await page.goto("apps/categories/analytics");
|
||||||
await appsPage.goToEventType("30 min");
|
await appsPage.installAnalyticsAppSkipConfigure(app);
|
||||||
await appsPage.goToAppsTab();
|
|
||||||
await appsPage.verifyAppsInfo(0);
|
await page.goto("/event-types");
|
||||||
for (const app of ALL_APPS) {
|
await appsPage.goToEventType("30 min");
|
||||||
await appsPage.activeApp(app);
|
await appsPage.goToAppsTab();
|
||||||
}
|
await appsPage.verifyAppsInfo(0);
|
||||||
await appsPage.verifyAppsInfo(6);
|
await appsPage.activeApp(app);
|
||||||
|
await appsPage.verifyAppsInfo(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
test.describe("check analytics apps using the new flow", () => {
|
||||||
test("Check analytics Apps using the new flow", async ({ appsPage, page, users }) => {
|
ALL_APPS.forEach((app) => {
|
||||||
const user = await users.create();
|
test(`check analytics app: ${app}`, async ({ appsPage, page, users }) => {
|
||||||
await user.apiLogin();
|
const user = await users.create();
|
||||||
const eventTypes = await user.getUserEventsAsOwner();
|
await user.apiLogin();
|
||||||
const eventTypesIds = eventTypes.map((item) => item.id);
|
const eventTypes = await user.getUserEventsAsOwner();
|
||||||
|
const eventTypesIds = eventTypes.map((item) => item.id);
|
||||||
for (const app of ALL_APPS) {
|
await page.goto("/apps/categories/analytics");
|
||||||
await page.goto("/apps/categories/analytics");
|
await appsPage.installAnalyticsApp(app, eventTypesIds);
|
||||||
await appsPage.installApp(app, eventTypesIds);
|
for (const id of eventTypesIds) {
|
||||||
}
|
await appsPage.verifyAppsInfoNew(app, id);
|
||||||
|
}
|
||||||
for (const id of eventTypesIds) {
|
});
|
||||||
await appsPage.verifyAppsInfoNew(ALL_APPS, id);
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { test } from "../../lib/fixtures";
|
||||||
|
|
||||||
|
export type TApp = {
|
||||||
|
slug: string;
|
||||||
|
type: string;
|
||||||
|
organizerInputPlaceholder?: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
type TAllApps = {
|
||||||
|
[key: string]: TApp;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_APPS: TAllApps = {
|
||||||
|
around: {
|
||||||
|
slug: "around",
|
||||||
|
type: "integrations:around_video",
|
||||||
|
organizerInputPlaceholder: "https://www.around.co/rick",
|
||||||
|
label: "Around Video",
|
||||||
|
},
|
||||||
|
campfire: {
|
||||||
|
slug: "campfire",
|
||||||
|
type: "integrations:campfire_video",
|
||||||
|
organizerInputPlaceholder: "https://party.campfire.to/your-team",
|
||||||
|
label: "Campfire",
|
||||||
|
},
|
||||||
|
demodesk: {
|
||||||
|
slug: "demodesk",
|
||||||
|
type: "integrations:demodesk_video",
|
||||||
|
organizerInputPlaceholder: "https://demodesk.com/meet/mylink",
|
||||||
|
label: "Demodesk",
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
slug: "discord",
|
||||||
|
type: "integrations:discord_video",
|
||||||
|
organizerInputPlaceholder: "https://discord.gg/420gg69",
|
||||||
|
label: "Discord",
|
||||||
|
},
|
||||||
|
eightxeight: {
|
||||||
|
slug: "eightxeight",
|
||||||
|
type: "integrations:eightxeight_video",
|
||||||
|
organizerInputPlaceholder: "https://8x8.vc/company",
|
||||||
|
label: "8x8",
|
||||||
|
},
|
||||||
|
"element-call": {
|
||||||
|
slug: "element-call",
|
||||||
|
type: "integrations:element-call_video",
|
||||||
|
organizerInputPlaceholder: "https://call.element.io/",
|
||||||
|
label: "Element Call",
|
||||||
|
},
|
||||||
|
facetime: {
|
||||||
|
slug: "facetime",
|
||||||
|
type: "integrations:facetime_video",
|
||||||
|
organizerInputPlaceholder: "https://facetime.apple.com/join=#v=1&p=zU9w7QzuEe",
|
||||||
|
label: "Facetime",
|
||||||
|
},
|
||||||
|
mirotalk: {
|
||||||
|
slug: "mirotalk",
|
||||||
|
type: "integrations:mirotalk_video",
|
||||||
|
organizerInputPlaceholder: "https://p2p.mirotalk.com/join/80085ShinyPhone",
|
||||||
|
label: "Mirotalk",
|
||||||
|
},
|
||||||
|
ping: {
|
||||||
|
slug: "ping",
|
||||||
|
type: "integrations:ping_video",
|
||||||
|
organizerInputPlaceholder: "https://www.ping.gg/call/theo",
|
||||||
|
label: "Ping.gg",
|
||||||
|
},
|
||||||
|
riverside: {
|
||||||
|
slug: "riverside",
|
||||||
|
type: "integrations:riverside_video",
|
||||||
|
organizerInputPlaceholder: "https://riverside.fm/studio/abc123",
|
||||||
|
label: "Riverside Video",
|
||||||
|
},
|
||||||
|
roam: {
|
||||||
|
slug: "roam",
|
||||||
|
type: "integrations:roam_video",
|
||||||
|
organizerInputPlaceholder: "https://ro.am/r/#/p/yHwFBQrRTMuptqKYo_wu8A/huzRiHnR-np4RGYKV-c0pQ",
|
||||||
|
label: "Roam",
|
||||||
|
},
|
||||||
|
salesroom: {
|
||||||
|
slug: "salesroom",
|
||||||
|
type: "integrations:salesroom_video",
|
||||||
|
organizerInputPlaceholder: "https://user.sr.chat",
|
||||||
|
label: "Salesroom",
|
||||||
|
},
|
||||||
|
sirius_video: {
|
||||||
|
slug: "sirius_video",
|
||||||
|
type: "integrations:sirius_video_video",
|
||||||
|
organizerInputPlaceholder: "https://sirius.video/sebastian",
|
||||||
|
label: "Sirius Video",
|
||||||
|
},
|
||||||
|
whereby: {
|
||||||
|
slug: "whereby",
|
||||||
|
type: "integrations:whereby_video",
|
||||||
|
label: "Whereby Video",
|
||||||
|
organizerInputPlaceholder: "https://www.whereby.com/cal",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_APPS_ARRAY: TApp[] = Object.values(ALL_APPS);
|
||||||
|
/**
|
||||||
|
* @todo add tests for
|
||||||
|
* shimmervideo
|
||||||
|
* sylapsvideo
|
||||||
|
* googlevideo
|
||||||
|
* huddle
|
||||||
|
* jelly
|
||||||
|
* jistivideo
|
||||||
|
* office365video
|
||||||
|
* mirotalk
|
||||||
|
* tandemvideo
|
||||||
|
* webex
|
||||||
|
* zoomvideo
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
|
test.describe("check non-oAuth link-based conferencing apps", () => {
|
||||||
|
ALL_APPS_ARRAY.forEach((app) => {
|
||||||
|
test(`check conferencing app: ${app.slug} by skipping the configure step`, async ({
|
||||||
|
appsPage,
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const user = await users.create();
|
||||||
|
await user.apiLogin();
|
||||||
|
await page.goto("apps/categories/conferencing");
|
||||||
|
await appsPage.installConferencingAppSkipConfigure(app.slug);
|
||||||
|
await appsPage.verifyConferencingApp(app);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("check non-oAuth link-based conferencing apps using the new flow", () => {
|
||||||
|
ALL_APPS_ARRAY.forEach((app) => {
|
||||||
|
test(`can add ${app.slug} app and book with it`, async ({ appsPage, page, users }) => {
|
||||||
|
const user = await users.create();
|
||||||
|
await user.apiLogin();
|
||||||
|
const eventTypes = await user.getUserEventsAsOwner();
|
||||||
|
const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 2);
|
||||||
|
await appsPage.installConferencingAppNewFlow(app, eventTypeIds);
|
||||||
|
await appsPage.verifyConferencingAppNew(app, eventTypeIds);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -119,7 +119,7 @@ test.describe("Availablity", () => {
|
|||||||
await expect(page.getByTestId("availablity-title")).toHaveValue("More working hours");
|
await expect(page.getByTestId("availablity-title")).toHaveValue("More working hours");
|
||||||
});
|
});
|
||||||
await test.step("Can delete a schedule", async () => {
|
await test.step("Can delete a schedule", async () => {
|
||||||
await page.getByRole("button", { name: /Go Back/i }).click();
|
await page.getByTestId("go-back-button").click();
|
||||||
await page.locator('[data-testid="schedules"] > li').nth(1).getByTestId("schedule-more").click();
|
await page.locator('[data-testid="schedules"] > li').nth(1).getByTestId("schedule-more").click();
|
||||||
await page.locator('[data-testid="delete-schedule"]').click();
|
await page.locator('[data-testid="delete-schedule"]').click();
|
||||||
const toast = await page.waitForSelector('[data-testid="toast-success"]');
|
const toast = await page.waitForSelector('[data-testid="toast-success"]');
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ test.describe("Bookings", () => {
|
|||||||
message: "first@cal.com marked as no-show",
|
message: "first@cal.com marked as no-show",
|
||||||
attendees: [{ email: "first@cal.com", noShow: true, utcOffset: null }],
|
attendees: [{ email: "first@cal.com", noShow: true, utcOffset: null }],
|
||||||
bookingUid: bookingWhereFirstUserIsOrganizer?.uid,
|
bookingUid: bookingWhereFirstUserIsOrganizer?.uid,
|
||||||
|
bookingId: bookingWhereFirstUserIsOrganizer?.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
webhookReceiver.close();
|
webhookReceiver.close();
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: null,
|
orgSlug: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "inline",
|
embedType: "inline",
|
||||||
calLink: `${pro.username}/30-min`,
|
calLink: `${pro.username}/30-min`,
|
||||||
@@ -96,7 +97,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: null,
|
orgSlug: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "floating-popup",
|
embedType: "floating-popup",
|
||||||
calLink: `${pro.username}/30-min`,
|
calLink: `${pro.username}/30-min`,
|
||||||
@@ -132,7 +135,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: null,
|
orgSlug: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "element-click",
|
embedType: "element-click",
|
||||||
calLink: `${pro.username}/30-min`,
|
calLink: `${pro.username}/30-min`,
|
||||||
@@ -170,8 +175,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: null,
|
orgSlug: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "inline",
|
embedType: "inline",
|
||||||
calLink: decodeURIComponent(embedUrl),
|
calLink: decodeURIComponent(embedUrl),
|
||||||
@@ -227,7 +233,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: org.slug,
|
orgSlug: org.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "inline",
|
embedType: "inline",
|
||||||
calLink: `${user.username}/30-min`,
|
calLink: `${user.username}/30-min`,
|
||||||
@@ -266,7 +274,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: org.slug,
|
orgSlug: org.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "floating-popup",
|
embedType: "floating-popup",
|
||||||
calLink: `${user.username}/30-min`,
|
calLink: `${user.username}/30-min`,
|
||||||
@@ -304,7 +314,9 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
orgSlug: org.slug,
|
orgSlug: org.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goToPreviewTab(page);
|
// To prevent early timeouts
|
||||||
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "element-click",
|
embedType: "element-click",
|
||||||
calLink: `${user.username}/30-min`,
|
calLink: `${user.username}/30-min`,
|
||||||
@@ -320,13 +332,6 @@ function chooseEmbedType(page: Page, embedType: EmbedType) {
|
|||||||
page.locator(`[data-testid=${embedType}]`).click();
|
page.locator(`[data-testid=${embedType}]`).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goToPreviewTab(page: Page) {
|
|
||||||
// To prevent early timeouts
|
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.locator("[data-testid=horizontal-tab-Preview]").click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToReactCodeTab(page: Page) {
|
async function goToReactCodeTab(page: Page) {
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { randomString } from "@calcom/lib/random";
|
|||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
|
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
|
||||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
import {
|
||||||
|
bookTimeSlot,
|
||||||
|
createNewEventType,
|
||||||
|
gotoBookingPage,
|
||||||
|
gotoFirstEventType,
|
||||||
|
saveEventType,
|
||||||
|
selectFirstAvailableTimeSlotNextMonth,
|
||||||
|
} from "./lib/testUtils";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
@@ -91,6 +98,8 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
|
|||||||
const firstFullSlug = await page.locator(`[data-testid=event-type-slug-${eventTypeId}]`).innerText();
|
const firstFullSlug = await page.locator(`[data-testid=event-type-slug-${eventTypeId}]`).innerText();
|
||||||
const firstSlug = firstFullSlug.split("/")[2];
|
const firstSlug = firstFullSlug.split("/")[2];
|
||||||
|
|
||||||
|
await expect(page.locator("[data-testid=readonly-badge]")).toBeHidden();
|
||||||
|
|
||||||
await page.click(`[data-testid=event-type-options-${eventTypeId}]`);
|
await page.click(`[data-testid=event-type-options-${eventTypeId}]`);
|
||||||
await page.click(`[data-testid=event-type-duplicate-${eventTypeId}]`);
|
await page.click(`[data-testid=event-type-duplicate-${eventTypeId}]`);
|
||||||
// Wait for the dialog to appear so we can get the URL
|
// Wait for the dialog to appear so we can get the URL
|
||||||
@@ -333,11 +342,11 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
|
|||||||
|
|
||||||
// Remove Both of the locations
|
// Remove Both of the locations
|
||||||
const removeButtomId = "delete-locations.0.type";
|
const removeButtomId = "delete-locations.0.type";
|
||||||
await page.getByTestId(removeButtomId).click();
|
await page.getByTestId(removeButtomId).nth(0).click();
|
||||||
await page.getByTestId(removeButtomId).click();
|
await page.getByTestId(removeButtomId).nth(0).click();
|
||||||
|
|
||||||
// Add Multiple Organizer Phone Number options
|
// Add Multiple Organizer Phone Number options
|
||||||
await page.getByTestId("location-select").click();
|
await page.getByTestId("location-select").last().click();
|
||||||
await page.locator(`text="Organizer Phone Number"`).click();
|
await page.locator(`text="Organizer Phone Number"`).click();
|
||||||
|
|
||||||
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
|
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
|
||||||
@@ -367,25 +376,6 @@ const selectAttendeePhoneNumber = async (page: Page) => {
|
|||||||
await page.locator(`text=${locationOptionText}`).click();
|
await page.locator(`text=${locationOptionText}`).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
async function gotoFirstEventType(page: Page) {
|
|
||||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
|
||||||
const firstEventTypeElement = $eventTypes.first();
|
|
||||||
await firstEventTypeElement.click();
|
|
||||||
await page.waitForURL((url) => {
|
|
||||||
return !!url.pathname.match(/\/event-types\/.+/);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEventType(page: Page) {
|
|
||||||
await page.locator("[data-testid=update-eventtype]").click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gotoBookingPage(page: Page) {
|
|
||||||
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
|
|
||||||
|
|
||||||
await page.goto(previewLink ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds n+1 location to the event type
|
* Adds n+1 location to the event type
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
import type { TApp } from "../apps/conferencing/conferencingApps.e2e";
|
||||||
import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding";
|
import {
|
||||||
|
bookTimeSlot,
|
||||||
|
gotoBookingPage,
|
||||||
|
gotoFirstEventType,
|
||||||
|
saveEventType,
|
||||||
|
selectFirstAvailableTimeSlotNextMonth,
|
||||||
|
} from "../lib/testUtils";
|
||||||
|
|
||||||
export function createAppsFixture(page: Page) {
|
export function createAppsFixture(page: Page) {
|
||||||
return {
|
return {
|
||||||
@@ -9,39 +15,92 @@ export function createAppsFixture(page: Page) {
|
|||||||
await page.getByTestId(`app-store-category-${category}`).nth(1).click();
|
await page.getByTestId(`app-store-category-${category}`).nth(1).click();
|
||||||
await page.goto("apps/categories/analytics");
|
await page.goto("apps/categories/analytics");
|
||||||
},
|
},
|
||||||
installAppSkipConfigure: async (app: string) => {
|
installAnalyticsAppSkipConfigure: async (app: string) => {
|
||||||
await page.getByTestId(`app-store-app-card-${app}`).click();
|
await page.getByTestId(`app-store-app-card-${app}`).click();
|
||||||
await page.getByTestId("install-app-button").click();
|
await page.getByTestId("install-app-button").click();
|
||||||
const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata];
|
await page.click('[data-testid="install-app-button-personal"]');
|
||||||
if (shouldRedirectToAppOnboarding(appMetadata)) {
|
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
|
||||||
await page.click('[data-testid="install-app-button-personal"]');
|
await page.click('[data-testid="set-up-later"]');
|
||||||
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
|
|
||||||
await page.click('[data-testid="set-up-later"]');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
installApp: async (app: string, eventTypeIds: number[]) => {
|
installAnalyticsApp: async (app: string, eventTypeIds: number[]) => {
|
||||||
await page.getByTestId(`app-store-app-card-${app}`).click();
|
await page.getByTestId(`app-store-app-card-${app}`).click();
|
||||||
(await page.waitForSelector('[data-testid="install-app-button"]')).click();
|
(await page.waitForSelector('[data-testid="install-app-button"]')).click();
|
||||||
|
|
||||||
const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata];
|
await page.click('[data-testid="install-app-button-personal"]');
|
||||||
if (shouldRedirectToAppOnboarding(appMetadata)) {
|
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
|
||||||
await page.click('[data-testid="install-app-button-personal"]');
|
|
||||||
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
|
|
||||||
|
|
||||||
for (const id of eventTypeIds) {
|
for (const id of eventTypeIds) {
|
||||||
await page.click(`[data-testid="select-event-type-${id}"]`);
|
await page.click(`[data-testid="select-event-type-${id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.click(`[data-testid="save-event-types"]`);
|
||||||
|
|
||||||
|
// adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it
|
||||||
|
if (app === "gtm") {
|
||||||
|
await page.waitForLoadState("domcontentloaded");
|
||||||
|
for (let index = 0; index < eventTypeIds.length; index++) {
|
||||||
|
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
await page.click(`[data-testid="configure-step-save"]`);
|
||||||
|
await page.waitForURL("/event-types");
|
||||||
|
},
|
||||||
|
|
||||||
await page.click(`[data-testid="save-event-types"]`);
|
installConferencingAppSkipConfigure: async (app: string) => {
|
||||||
|
await page.getByTestId(`app-store-app-card-${app}`).click();
|
||||||
|
await page.getByTestId("install-app-button").click();
|
||||||
|
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
|
||||||
|
await page.click('[data-testid="set-up-later"]');
|
||||||
|
},
|
||||||
|
verifyConferencingApp: async (app: TApp) => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
await gotoFirstEventType(page);
|
||||||
|
await page.getByTestId("location-select").last().click();
|
||||||
|
await page.getByTestId(`location-select-item-${app.type}`).click();
|
||||||
|
if (app.organizerInputPlaceholder) {
|
||||||
|
await page.getByTestId(`${app.type}-location-input`).fill(app.organizerInputPlaceholder);
|
||||||
|
}
|
||||||
|
await page.locator("[data-testid=display-location]").last().check();
|
||||||
|
await saveEventType(page);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await gotoBookingPage(page);
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
await bookTimeSlot(page);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
if (app === "gtm") {
|
await expect(page.locator("[data-testid=where] ")).toContainText(app.label);
|
||||||
await page.waitForLoadState("domcontentloaded");
|
},
|
||||||
for (let index = 0; index < eventTypeIds.length; index++) {
|
|
||||||
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id");
|
installConferencingAppNewFlow: async (app: TApp, eventTypeIds: number[]) => {
|
||||||
}
|
await page.goto("apps/categories/conferencing");
|
||||||
}
|
await page.getByTestId(`app-store-app-card-${app.slug}`).click();
|
||||||
await page.click(`[data-testid="configure-step-save"]`);
|
await page.getByTestId("install-app-button").click();
|
||||||
|
await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`);
|
||||||
|
|
||||||
|
for (const id of eventTypeIds) {
|
||||||
|
await page.click(`[data-testid="select-event-type-${id}"]`);
|
||||||
|
}
|
||||||
|
await page.click(`[data-testid="save-event-types"]`);
|
||||||
|
|
||||||
|
for (let eindex = 0; eindex < eventTypeIds.length; eindex++) {
|
||||||
|
if (!app.organizerInputPlaceholder) continue;
|
||||||
|
await page.getByTestId(`${app.type}-location-input`).nth(eindex).fill(app.organizerInputPlaceholder);
|
||||||
|
}
|
||||||
|
await page.click(`[data-testid="configure-step-save"]`);
|
||||||
|
await page.waitForURL("/event-types");
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyConferencingAppNew: async (app: TApp, eventTypeIds: number[]) => {
|
||||||
|
for (const id of eventTypeIds) {
|
||||||
|
await page.goto(`/event-types/${id}`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await gotoBookingPage(page);
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
await bookTimeSlot(page, { name: `Test Testson`, email: `test@example.com` });
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
await expect(page.locator("[data-testid=where] ")).toContainText(app.label);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
goBackToAppsPage: async () => {
|
goBackToAppsPage: async () => {
|
||||||
@@ -57,14 +116,12 @@ export function createAppsFixture(page: Page) {
|
|||||||
await page.locator(`[data-testid='${app}-app-switch']`).click();
|
await page.locator(`[data-testid='${app}-app-switch']`).click();
|
||||||
},
|
},
|
||||||
verifyAppsInfo: async (activeApps: number) => {
|
verifyAppsInfo: async (activeApps: number) => {
|
||||||
await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible();
|
await expect(page.locator(`text=1 apps, ${activeApps} active`)).toBeVisible();
|
||||||
},
|
},
|
||||||
verifyAppsInfoNew: async (apps: string[], eventTypeId: number) => {
|
verifyAppsInfoNew: async (app: string, eventTypeId: number) => {
|
||||||
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
|
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
|
||||||
await page.waitForLoadState("domcontentloaded");
|
await page.waitForLoadState("domcontentloaded");
|
||||||
for (const app of apps) {
|
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
|
||||||
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ export function createWorkflowPageFixture(page: Page) {
|
|||||||
await selectEventType("30 min");
|
await selectEventType("30 min");
|
||||||
}
|
}
|
||||||
await saveWorkflow();
|
await saveWorkflow();
|
||||||
|
await page.getByTestId("go-back-button").click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveWorkflow = async () => {
|
const saveWorkflow = async () => {
|
||||||
@@ -71,7 +72,7 @@ export function createWorkflowPageFixture(page: Page) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectEventType = async (name: string) => {
|
const selectEventType = async (name: string) => {
|
||||||
await page.getByText("Select...").click();
|
await page.getByTestId("multi-select-check-boxes").click();
|
||||||
await page.getByText(name, { exact: true }).click();
|
await page.getByText(name, { exact: true }).click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -371,3 +371,22 @@ export async function doOnOrgDomain(
|
|||||||
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
|
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
|
||||||
export const NotFoundPageTextAppDir = "This page does not exist.";
|
export const NotFoundPageTextAppDir = "This page does not exist.";
|
||||||
// export const NotFoundPageText = "ERROR 404";
|
// export const NotFoundPageText = "ERROR 404";
|
||||||
|
|
||||||
|
export async function gotoFirstEventType(page: Page) {
|
||||||
|
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||||
|
const firstEventTypeElement = $eventTypes.first();
|
||||||
|
await firstEventTypeElement.click();
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
return !!url.pathname.match(/\/event-types\/.+/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gotoBookingPage(page: Page) {
|
||||||
|
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
|
||||||
|
|
||||||
|
await page.goto(previewLink ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEventType(page: Page) {
|
||||||
|
await page.locator("[data-testid=update-eventtype]").click();
|
||||||
|
}
|
||||||
@@ -155,7 +155,7 @@ test.describe("Organization Logo", async () => {
|
|||||||
|
|
||||||
await page.getByTestId("upload-avatar").click();
|
await page.getByTestId("upload-avatar").click();
|
||||||
|
|
||||||
await page.getByText("Update").click();
|
await page.getByTestId("update-org-profile-button").click();
|
||||||
await page.waitForSelector("text=Your organization updated successfully");
|
await page.waitForSelector("text=Your organization updated successfully");
|
||||||
|
|
||||||
const response = await prisma.avatar.findUniqueOrThrow({
|
const response = await prisma.avatar.findUniqueOrThrow({
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
|
|||||||
await user.apiLogin();
|
await user.apiLogin();
|
||||||
|
|
||||||
page.goto(`/settings/teams/${team.id}/onboard-members`);
|
page.goto(`/settings/teams/${team.id}/onboard-members`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await test.step("Can add members", async () => {
|
await test.step("Can add members", async () => {
|
||||||
// Click [data-testid="new-member-button"]
|
// Click [data-testid="new-member-button"]
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ test.describe("Workflow Tab - Event Type", () => {
|
|||||||
await assertListCount(3);
|
await assertListCount(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Editing an existing workflow", async ({ workflowPage }) => {
|
test("Editing an existing workflow", async ({ workflowPage, page }) => {
|
||||||
const { saveWorkflow, fillNameInput, editSelectedWorkflow, hasWorkflowInList } = workflowPage;
|
const { saveWorkflow, fillNameInput, editSelectedWorkflow, hasWorkflowInList } = workflowPage;
|
||||||
|
|
||||||
await editSelectedWorkflow("Test Workflow");
|
await editSelectedWorkflow("Test Workflow");
|
||||||
await fillNameInput("Edited Workflow");
|
await fillNameInput("Edited Workflow");
|
||||||
await saveWorkflow();
|
await saveWorkflow();
|
||||||
|
await page.getByTestId("go-back-button").click();
|
||||||
await hasWorkflowInList("Edited Workflow");
|
await hasWorkflowInList("Edited Workflow");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -879,7 +879,7 @@
|
|||||||
"add_new_event_type": "Neuen Termintyp hinzufügen",
|
"add_new_event_type": "Neuen Termintyp hinzufügen",
|
||||||
"new_event_type_to_book_description": "Erstellen Sie einen neuen Termintyp, mit dem Personen Zeiten buchen können.",
|
"new_event_type_to_book_description": "Erstellen Sie einen neuen Termintyp, mit dem Personen Zeiten buchen können.",
|
||||||
"length": "Länge",
|
"length": "Länge",
|
||||||
"minimum_booking_notice": "Mindesvorlaufzeit für eine Buchung",
|
"minimum_booking_notice": "Mindestvorlaufzeit für eine Buchung",
|
||||||
"offset_toggle": "Versetzte Startzeiten",
|
"offset_toggle": "Versetzte Startzeiten",
|
||||||
"offset_toggle_description": "Versetzt die den Buchern angezeigten Zeitfenster um eine bestimmte Anzahl von Minuten",
|
"offset_toggle_description": "Versetzt die den Buchern angezeigten Zeitfenster um eine bestimmte Anzahl von Minuten",
|
||||||
"offset_start": "Versetzt um",
|
"offset_start": "Versetzt um",
|
||||||
|
|||||||
@@ -464,6 +464,7 @@
|
|||||||
"booking_cancelled": "Booking Canceled",
|
"booking_cancelled": "Booking Canceled",
|
||||||
"booking_rescheduled": "Booking Rescheduled",
|
"booking_rescheduled": "Booking Rescheduled",
|
||||||
"recording_ready": "Recording Download Link Ready",
|
"recording_ready": "Recording Download Link Ready",
|
||||||
|
"recording_transcription_generated": "Transcript Generated",
|
||||||
"booking_created": "Booking Created",
|
"booking_created": "Booking Created",
|
||||||
"booking_rejected": "Booking Rejected",
|
"booking_rejected": "Booking Rejected",
|
||||||
"booking_requested": "Booking Requested",
|
"booking_requested": "Booking Requested",
|
||||||
@@ -1238,6 +1239,8 @@
|
|||||||
"may_require_confirmation": "May require confirmation",
|
"may_require_confirmation": "May require confirmation",
|
||||||
"nr_event_type_one": "{{count}} event type",
|
"nr_event_type_one": "{{count}} event type",
|
||||||
"nr_event_type_other": "{{count}} event types",
|
"nr_event_type_other": "{{count}} event types",
|
||||||
|
"count_team_one": "{{count}} team",
|
||||||
|
"count_team_other": "{{count}} teams",
|
||||||
"add_action": "Add action",
|
"add_action": "Add action",
|
||||||
"set_whereby_link": "Set Whereby link",
|
"set_whereby_link": "Set Whereby link",
|
||||||
"invalid_whereby_link": "Please enter a valid Whereby Link",
|
"invalid_whereby_link": "Please enter a valid Whereby Link",
|
||||||
@@ -1275,7 +1278,12 @@
|
|||||||
"problem_updating_calendar": "There was a problem updating your calendar",
|
"problem_updating_calendar": "There was a problem updating your calendar",
|
||||||
"active_on_event_types_one": "Active on {{count}} event type",
|
"active_on_event_types_one": "Active on {{count}} event type",
|
||||||
"active_on_event_types_other": "Active on {{count}} event types",
|
"active_on_event_types_other": "Active on {{count}} event types",
|
||||||
|
"active_on_teams_one": "Active on {{count}} team",
|
||||||
|
"active_on_teams_other": "Active on {{count}} teams",
|
||||||
|
"active_on_all_event_types": "Active on all event types",
|
||||||
|
"active_on_all_teams": "Active on all teams",
|
||||||
"no_active_event_types": "No active event types",
|
"no_active_event_types": "No active event types",
|
||||||
|
"no_active_teams": "No active teams",
|
||||||
"new_seat_subject": "New Attendee {{name}} on {{eventType}} at {{date}}",
|
"new_seat_subject": "New Attendee {{name}} on {{eventType}} at {{date}}",
|
||||||
"new_seat_title": "Someone has added themselves to an event",
|
"new_seat_title": "Someone has added themselves to an event",
|
||||||
"variable": "Variable",
|
"variable": "Variable",
|
||||||
@@ -1341,6 +1349,9 @@
|
|||||||
"2fa_required": "Two factor authentication required",
|
"2fa_required": "Two factor authentication required",
|
||||||
"incorrect_2fa": "Incorrect two factor authentication code",
|
"incorrect_2fa": "Incorrect two factor authentication code",
|
||||||
"which_event_type_apply": "Which event type will this apply to?",
|
"which_event_type_apply": "Which event type will this apply to?",
|
||||||
|
"apply_to_all_event_types": "Apply to all, including future event types",
|
||||||
|
"apply_to_all_teams": "Apply to all team and user event types",
|
||||||
|
"which_team_apply": "Which team will this apply to?",
|
||||||
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
|
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
|
||||||
"timeformat_profile_hint": "This is an internal setting and will not affect how times are displayed on public booking pages for you or anyone booking you.",
|
"timeformat_profile_hint": "This is an internal setting and will not affect how times are displayed on public booking pages for you or anyone booking you.",
|
||||||
"create_workflow": "Create a workflow",
|
"create_workflow": "Create a workflow",
|
||||||
@@ -1750,6 +1761,7 @@
|
|||||||
"new_attendee": "New Attendee",
|
"new_attendee": "New Attendee",
|
||||||
"awaiting_approval": "Awaiting Approval",
|
"awaiting_approval": "Awaiting Approval",
|
||||||
"requires_google_calendar": "This app requires a Google Calendar connection",
|
"requires_google_calendar": "This app requires a Google Calendar connection",
|
||||||
|
"event_type_requires_google_calendar": "The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. Connect it <1>here</1>.",
|
||||||
"connected_google_calendar": "You have connected a Google Calendar account.",
|
"connected_google_calendar": "You have connected a Google Calendar account.",
|
||||||
"using_meet_requires_calendar": "Using Google Meet requires a connected Google Calendar",
|
"using_meet_requires_calendar": "Using Google Meet requires a connected Google Calendar",
|
||||||
"continue_to_install_google_calendar": "Continue to install Google Calendar",
|
"continue_to_install_google_calendar": "Continue to install Google Calendar",
|
||||||
@@ -2463,7 +2475,6 @@
|
|||||||
"lowest_rated_members": "Members with lowest rated meetings",
|
"lowest_rated_members": "Members with lowest rated meetings",
|
||||||
"csat_score": "CSAT Score",
|
"csat_score": "CSAT Score",
|
||||||
"lockedSMS": "Locked SMS",
|
"lockedSMS": "Locked SMS",
|
||||||
"signing_up_terms": "By proceeding, you agree to our <1>Terms</1> and <2>Privacy Policy</2>.",
|
|
||||||
"leave_without_assigning_anyone": "Leave without assigning anyone?",
|
"leave_without_assigning_anyone": "Leave without assigning anyone?",
|
||||||
"leave_without_adding_attendees": "Are you sure want to leave this event without adding attendees?",
|
"leave_without_adding_attendees": "Are you sure want to leave this event without adding attendees?",
|
||||||
"no_availability_shown_to_bookers": "If you don't assign anyone for this event, there will be no availability shown to bookers.",
|
"no_availability_shown_to_bookers": "If you don't assign anyone for this event, there will be no availability shown to bookers.",
|
||||||
@@ -2477,10 +2488,13 @@
|
|||||||
"mark_as_no_show_title": "Mark as no show",
|
"mark_as_no_show_title": "Mark as no show",
|
||||||
"x_marked_as_no_show": "{{x}} marked as no-show",
|
"x_marked_as_no_show": "{{x}} marked as no-show",
|
||||||
"x_unmarked_as_no_show": "{{x}} unmarked as no-show",
|
"x_unmarked_as_no_show": "{{x}} unmarked as no-show",
|
||||||
|
"team_select_info": "triggers for all team event types and all team members' personal event types",
|
||||||
"no_show_updated": "No-show status updated for attendees",
|
"no_show_updated": "No-show status updated for attendees",
|
||||||
"email_copied": "Email copied",
|
"email_copied": "Email copied",
|
||||||
"USER_PENDING_MEMBER_OF_THE_ORG": "User is a pending member of the organization",
|
"USER_PENDING_MEMBER_OF_THE_ORG": "User is a pending member of the organization",
|
||||||
"USER_ALREADY_INVITED_OR_MEMBER": "User is already invited or a member",
|
"USER_ALREADY_INVITED_OR_MEMBER": "User is already invited or a member",
|
||||||
"USER_MEMBER_OF_OTHER_ORGANIZATION": "User is member of an organization that this team is not a part of.",
|
"USER_MEMBER_OF_OTHER_ORGANIZATION": "User is member of an organization that this team is not a part of.",
|
||||||
|
"rescheduling_not_possible": "Rescheduling is not possible as the event has expired",
|
||||||
|
"event_expired": "This event is expired",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
@@ -1419,24 +1419,21 @@ describe("getSchedule", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scenario = await createBookingScenario(
|
const scenario = await createBookingScenario(
|
||||||
getScenarioData(
|
getScenarioData({
|
||||||
{
|
eventTypes: [
|
||||||
eventTypes: [
|
{
|
||||||
{
|
id: 1,
|
||||||
id: 1,
|
slotInterval: 45,
|
||||||
slotInterval: 45,
|
length: 45,
|
||||||
length: 45,
|
users: [
|
||||||
users: [
|
{
|
||||||
{
|
id: 101,
|
||||||
id: 101,
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
organizer,
|
||||||
organizer,
|
})
|
||||||
},
|
|
||||||
{ id: org.id }
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import logger from "@calcom/lib/logger";
|
|||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||||
import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client";
|
import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client";
|
||||||
import type { SchedulingType, SMSLockState } from "@calcom/prisma/enums";
|
import type { SchedulingType, SMSLockState, TimeUnit } from "@calcom/prisma/enums";
|
||||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||||
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { userMetadataType } from "@calcom/prisma/zod-utils";
|
import type { userMetadataType } from "@calcom/prisma/zod-utils";
|
||||||
@@ -46,10 +46,14 @@ type InputWorkflow = {
|
|||||||
userId?: number | null;
|
userId?: number | null;
|
||||||
teamId?: number | null;
|
teamId?: number | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
activeEventTypeId?: number;
|
activeOn?: number[];
|
||||||
|
activeOnTeams?: number[];
|
||||||
trigger: WorkflowTriggerEvents;
|
trigger: WorkflowTriggerEvents;
|
||||||
action: WorkflowActions;
|
action: WorkflowActions;
|
||||||
template: WorkflowTemplates;
|
template: WorkflowTemplates;
|
||||||
|
time?: number | null;
|
||||||
|
timeUnit?: TimeUnit | null;
|
||||||
|
sendTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputHost = {
|
type InputHost = {
|
||||||
@@ -95,6 +99,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
parentId?: number;
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
schedules: {
|
schedules: {
|
||||||
@@ -130,6 +135,10 @@ export type InputEventType = {
|
|||||||
beforeEventBuffer?: number;
|
beforeEventBuffer?: number;
|
||||||
afterEventBuffer?: number;
|
afterEventBuffer?: number;
|
||||||
teamId?: number | null;
|
teamId?: number | null;
|
||||||
|
team?: {
|
||||||
|
id?: number | null;
|
||||||
|
parentId?: number | null;
|
||||||
|
};
|
||||||
requiresConfirmation?: boolean;
|
requiresConfirmation?: boolean;
|
||||||
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
||||||
schedule?: InputUser["schedules"][number];
|
schedule?: InputUser["schedules"][number];
|
||||||
@@ -444,32 +453,75 @@ async function addWebhooks(webhooks: InputWebhook[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addWorkflowsToDb(workflows: InputWorkflow[]) {
|
async function addWorkflowsToDb(workflows: InputWorkflow[]) {
|
||||||
await prismock.$transaction(
|
await Promise.all(
|
||||||
workflows.map((workflow) => {
|
workflows.map(async (workflow) => {
|
||||||
return prismock.workflow.create({
|
const team = await prismock.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: workflow.teamId ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workflow.teamId && !team) {
|
||||||
|
throw new Error(`Team with ID ${workflow.teamId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrg = team?.isOrganization;
|
||||||
|
|
||||||
|
// Create the workflow first
|
||||||
|
const createdWorkflow = await prismock.workflow.create({
|
||||||
data: {
|
data: {
|
||||||
userId: workflow.userId,
|
userId: workflow.userId,
|
||||||
teamId: workflow.teamId,
|
teamId: workflow.teamId,
|
||||||
trigger: workflow.trigger,
|
trigger: workflow.trigger,
|
||||||
name: workflow.name ? workflow.name : "Test Workflow",
|
name: workflow.name ? workflow.name : "Test Workflow",
|
||||||
steps: {
|
time: workflow.time,
|
||||||
create: {
|
timeUnit: workflow.timeUnit,
|
||||||
stepNumber: 1,
|
|
||||||
action: workflow.action,
|
|
||||||
template: workflow.template,
|
|
||||||
numberVerificationPending: false,
|
|
||||||
includeCalendarEvent: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
activeOn: {
|
|
||||||
create: workflow.activeEventTypeId ? { eventTypeId: workflow.activeEventTypeId } : undefined,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
activeOn: true,
|
|
||||||
steps: true,
|
steps: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prismock.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
stepNumber: 1,
|
||||||
|
action: workflow.action,
|
||||||
|
template: workflow.template,
|
||||||
|
numberVerificationPending: false,
|
||||||
|
includeCalendarEvent: false,
|
||||||
|
sendTo: workflow.sendTo,
|
||||||
|
workflow: {
|
||||||
|
connect: {
|
||||||
|
id: createdWorkflow.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//activate event types and teams on workflows
|
||||||
|
if (isOrg && workflow.activeOnTeams) {
|
||||||
|
await Promise.all(
|
||||||
|
workflow.activeOnTeams.map((id) =>
|
||||||
|
prismock.workflowsOnTeams.create({
|
||||||
|
data: {
|
||||||
|
workflowId: createdWorkflow.id,
|
||||||
|
teamId: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (workflow.activeOn) {
|
||||||
|
await Promise.all(
|
||||||
|
workflow.activeOn.map((id) =>
|
||||||
|
prismock.workflowsOnEventTypes.create({
|
||||||
|
data: {
|
||||||
|
workflowId: createdWorkflow.id,
|
||||||
|
eventTypeId: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,9 +562,25 @@ export async function addUsersToDb(
|
|||||||
|
|
||||||
export async function addTeamsToDb(teams: NonNullable<InputUser["teams"]>[number]["team"][]) {
|
export async function addTeamsToDb(teams: NonNullable<InputUser["teams"]>[number]["team"][]) {
|
||||||
log.silly("TestData: Creating Teams", JSON.stringify(teams));
|
log.silly("TestData: Creating Teams", JSON.stringify(teams));
|
||||||
await prismock.team.createMany({
|
|
||||||
data: teams,
|
for (const team of teams) {
|
||||||
});
|
const teamsWithParentId = {
|
||||||
|
...team,
|
||||||
|
parentId: team.parentId,
|
||||||
|
};
|
||||||
|
await prismock.team.upsert({
|
||||||
|
where: {
|
||||||
|
id: teamsWithParentId.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...teamsWithParentId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...teamsWithParentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const addedTeams = await prismock.team.findMany({
|
const addedTeams = await prismock.team.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
@@ -643,17 +711,34 @@ export async function createOrganization(orgData: {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
metadata?: z.infer<typeof teamMetadataSchema>;
|
metadata?: z.infer<typeof teamMetadataSchema>;
|
||||||
|
withTeam?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const org = await prismock.team.create({
|
const org = await prismock.team.create({
|
||||||
data: {
|
data: {
|
||||||
name: orgData.name,
|
name: orgData.name,
|
||||||
slug: orgData.slug,
|
slug: orgData.slug,
|
||||||
|
isOrganization: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
...(orgData.metadata || {}),
|
...(orgData.metadata || {}),
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (orgData.withTeam) {
|
||||||
|
await prismock.team.create({
|
||||||
|
data: {
|
||||||
|
name: "Org Team",
|
||||||
|
slug: "org-team",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: {
|
||||||
|
connect: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return org;
|
return org;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1125,6 +1210,10 @@ export function getScenarioData(
|
|||||||
return {
|
return {
|
||||||
...eventType,
|
...eventType,
|
||||||
teamId: eventType.teamId || null,
|
teamId: eventType.teamId || null,
|
||||||
|
team: {
|
||||||
|
id: eventType.teamId,
|
||||||
|
parentId: org ? org.id : null,
|
||||||
|
},
|
||||||
title: `Test Event Type - ${index + 1}`,
|
title: `Test Event Type - ${index + 1}`,
|
||||||
description: `It's a test event type - ${index + 1}`,
|
description: `It's a test event type - ${index + 1}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -350,33 +350,54 @@ export function expectWorkflowToBeNotTriggered({
|
|||||||
export function expectSMSWorkflowToBeTriggered({
|
export function expectSMSWorkflowToBeTriggered({
|
||||||
sms,
|
sms,
|
||||||
toNumber,
|
toNumber,
|
||||||
|
includedString,
|
||||||
}: {
|
}: {
|
||||||
sms: Fixtures["sms"];
|
sms: Fixtures["sms"];
|
||||||
toNumber: string;
|
toNumber: string;
|
||||||
|
includedString?: string;
|
||||||
}) {
|
}) {
|
||||||
expect(sms.get()).toEqual(
|
const allSMS = sms.get();
|
||||||
expect.arrayContaining([
|
if (includedString) {
|
||||||
expect.objectContaining({
|
const messageWithIncludedString = allSMS.find((sms) => sms.message.includes(includedString));
|
||||||
to: toNumber,
|
|
||||||
}),
|
expect(messageWithIncludedString?.to).toBe(toNumber);
|
||||||
])
|
} else {
|
||||||
);
|
expect(allSMS).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
to: toNumber,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectSMSWorkflowToBeNotTriggered({
|
export function expectSMSWorkflowToBeNotTriggered({
|
||||||
sms,
|
sms,
|
||||||
toNumber,
|
toNumber,
|
||||||
|
includedString,
|
||||||
}: {
|
}: {
|
||||||
sms: Fixtures["sms"];
|
sms: Fixtures["sms"];
|
||||||
toNumber: string;
|
toNumber: string;
|
||||||
|
includedString?: string;
|
||||||
}) {
|
}) {
|
||||||
expect(sms.get()).not.toEqual(
|
const allSMS = sms.get();
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
if (includedString) {
|
||||||
to: toNumber,
|
const messageWithIncludedString = allSMS.find((sms) => sms.message.includes(includedString));
|
||||||
}),
|
|
||||||
])
|
if (messageWithIncludedString) {
|
||||||
);
|
expect(messageWithIncludedString?.to).not.toBe(toNumber);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(allSMS).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
to: toNumber,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectBookingToBeInDatabase(
|
export async function expectBookingToBeInDatabase(
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
|
||||||
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
|
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
|
||||||
|
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
|
||||||
import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma";
|
import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma";
|
||||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
import type { AppFrontendPayload as App } from "@calcom/types/App";
|
import type { AppFrontendPayload as App } from "@calcom/types/App";
|
||||||
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
|
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
|
||||||
|
|
||||||
|
export type TDependencyData = {
|
||||||
|
name?: string;
|
||||||
|
installed?: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get App metdata either using dirName or slug
|
* Get App metdata either using dirName or slug
|
||||||
*/
|
*/
|
||||||
@@ -58,19 +63,13 @@ export async function getAppRegistry() {
|
|||||||
|
|
||||||
export async function getAppRegistryWithCredentials(userId: number, userAdminTeams: UserAdminTeams = []) {
|
export async function getAppRegistryWithCredentials(userId: number, userAdminTeams: UserAdminTeams = []) {
|
||||||
// Get teamIds to grab existing credentials
|
// Get teamIds to grab existing credentials
|
||||||
const teamIds = [];
|
|
||||||
for (const team of userAdminTeams) {
|
|
||||||
if (!team.isUser) {
|
|
||||||
teamIds.push(team.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbApps = await prisma.app.findMany({
|
const dbApps = await prisma.app.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
select: {
|
select: {
|
||||||
...safeAppSelect,
|
...safeAppSelect,
|
||||||
credentials: {
|
credentials: {
|
||||||
where: { OR: [{ userId }, { teamId: { in: teamIds } }] },
|
where: { OR: [{ userId }, { teamId: { in: userAdminTeams } }] },
|
||||||
select: safeCredentialSelect,
|
select: safeCredentialSelect,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -102,10 +101,7 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea
|
|||||||
/* This is now handled from the DB */
|
/* This is now handled from the DB */
|
||||||
// if (!app.installed) return apps;
|
// if (!app.installed) return apps;
|
||||||
app.createdAt = dbapp.createdAt.toISOString();
|
app.createdAt = dbapp.createdAt.toISOString();
|
||||||
let dependencyData: {
|
let dependencyData: TDependencyData = [];
|
||||||
name?: string;
|
|
||||||
installed?: boolean;
|
|
||||||
}[] = [];
|
|
||||||
if (app.dependencies) {
|
if (app.dependencies) {
|
||||||
dependencyData = app.dependencies.map((dependency) => {
|
dependencyData = app.dependencies.map((dependency) => {
|
||||||
const dependencyInstalled = dbApps.some(
|
const dependencyInstalled = dbApps.some(
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export default function OmniInstallAppButton({
|
|||||||
type: app.type,
|
type: app.type,
|
||||||
variant: app.variant,
|
variant: app.variant,
|
||||||
slug: app.slug,
|
slug: app.slug,
|
||||||
isOmniInstall: true,
|
|
||||||
...(teamId && { teamId }),
|
...(teamId && { teamId }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ export function decodeOAuthState(req: NextApiRequest) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
|
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
|
||||||
if (state.appOnboardingRedirectUrl) {
|
|
||||||
state.appOnboardingRedirectUrl = decodeURIComponent(state.appOnboardingRedirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
|
|
||||||
export const throwIfNotHaveAdminAccessToTeam = async ({
|
export const throwIfNotHaveAdminAccessToTeam = async ({
|
||||||
teamId,
|
teamId,
|
||||||
@@ -11,8 +11,8 @@ export const throwIfNotHaveAdminAccessToTeam = async ({
|
|||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const teamsUserHasAdminAccessFor = await getUserAdminTeams({ userId });
|
const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId);
|
||||||
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((team) => team.id === teamId);
|
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId);
|
||||||
|
|
||||||
if (!hasAdminAccessToTeam) {
|
if (!hasAdminAccessToTeam) {
|
||||||
throw new HttpError({ statusCode: 401, message: "You must be an admin of the team to do this" });
|
throw new HttpError({ statusCode: 401, message: "You must be an admin of the team to do this" });
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types";
|
|||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
|
|
||||||
import getInstalledAppPath from "./getInstalledAppPath";
|
|
||||||
|
|
||||||
function gotoUrl(url: string, newTab?: boolean) {
|
function gotoUrl(url: string, newTab?: boolean) {
|
||||||
if (newTab) {
|
if (newTab) {
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
@@ -21,23 +19,13 @@ type CustomUseMutationOptions =
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
type AddAppMutationData = { setupPending: boolean } | void;
|
type AddAppMutationData = { setupPending: boolean } | void;
|
||||||
type UseAddAppMutationOptions = CustomUseMutationOptions & {
|
export type UseAddAppMutationOptions = CustomUseMutationOptions & {
|
||||||
onSuccess?: (data: AddAppMutationData) => void;
|
onSuccess?: (data: AddAppMutationData) => void;
|
||||||
installGoogleVideo?: boolean;
|
installGoogleVideo?: boolean;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type useAddAppMutationVariables = {
|
function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutationOptions) {
|
||||||
type?: App["type"];
|
|
||||||
variant?: string;
|
|
||||||
slug?: string;
|
|
||||||
isOmniInstall?: boolean;
|
|
||||||
teamId?: number;
|
|
||||||
onErrorReturnTo?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMutationOptions) {
|
|
||||||
const { returnTo, ...options } = allOptions || {};
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const onErrorReturnTo = `${WEBAPP_URL}${pathname}`;
|
const onErrorReturnTo = `${WEBAPP_URL}${pathname}`;
|
||||||
|
|
||||||
@@ -48,8 +36,8 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
|
|||||||
type?: App["type"];
|
type?: App["type"];
|
||||||
variant?: string;
|
variant?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
isOmniInstall?: boolean;
|
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
returnTo?: string;
|
||||||
defaultInstall?: boolean;
|
defaultInstall?: boolean;
|
||||||
}
|
}
|
||||||
| ""
|
| ""
|
||||||
@@ -57,13 +45,16 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
|
|||||||
...options,
|
...options,
|
||||||
mutationFn: async (variables) => {
|
mutationFn: async (variables) => {
|
||||||
let type: string | null | undefined;
|
let type: string | null | undefined;
|
||||||
let isOmniInstall;
|
|
||||||
const teamId = variables && variables.teamId ? variables.teamId : undefined;
|
const teamId = variables && variables.teamId ? variables.teamId : undefined;
|
||||||
const defaultInstall = variables && variables.defaultInstall ? variables.defaultInstall : undefined;
|
const defaultInstall = variables && variables.defaultInstall ? variables.defaultInstall : undefined;
|
||||||
|
const returnTo = options?.returnTo
|
||||||
|
? options.returnTo
|
||||||
|
: variables && variables.returnTo
|
||||||
|
? variables.returnTo
|
||||||
|
: undefined;
|
||||||
if (variables === "") {
|
if (variables === "") {
|
||||||
type = _type;
|
type = _type;
|
||||||
} else {
|
} else {
|
||||||
isOmniInstall = variables.isOmniInstall;
|
|
||||||
type = variables.type;
|
type = variables.type;
|
||||||
}
|
}
|
||||||
if (type?.endsWith("_other_calendar")) {
|
if (type?.endsWith("_other_calendar")) {
|
||||||
@@ -74,22 +65,20 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
|
|||||||
throw new Error("Could not install Google Meet");
|
throw new Error("Could not install Google Meet");
|
||||||
|
|
||||||
const state: IntegrationOAuthCallbackState = {
|
const state: IntegrationOAuthCallbackState = {
|
||||||
returnTo:
|
|
||||||
returnTo ||
|
|
||||||
WEBAPP_URL +
|
|
||||||
getInstalledAppPath(
|
|
||||||
{ variant: variables && variables.variant, slug: variables && variables.slug },
|
|
||||||
location.search
|
|
||||||
),
|
|
||||||
onErrorReturnTo,
|
onErrorReturnTo,
|
||||||
fromApp: true,
|
fromApp: true,
|
||||||
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
|
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
|
||||||
...(teamId && { teamId }),
|
...(teamId && { teamId }),
|
||||||
|
...(returnTo && { returnTo }),
|
||||||
...(defaultInstall && { defaultInstall }),
|
...(defaultInstall && { defaultInstall }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateStr = JSON.stringify(state);
|
const stateStr = JSON.stringify(state);
|
||||||
const searchParams = generateSearchParamString({ stateStr, teamId, returnTo });
|
const searchParams = generateSearchParamString({
|
||||||
|
stateStr,
|
||||||
|
teamId,
|
||||||
|
returnTo,
|
||||||
|
});
|
||||||
|
|
||||||
const res = await fetch(`/api/integrations/${type}/add${searchParams}`);
|
const res = await fetch(`/api/integrations/${type}/add${searchParams}`);
|
||||||
|
|
||||||
@@ -99,23 +88,25 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
|
|||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const externalUrl = /https?:\/\//.test(json.url) && !json.url.startsWith(window.location.origin);
|
const externalUrl = /https?:\/\//.test(json?.url) && !json?.url?.startsWith(window.location.origin);
|
||||||
if (!isOmniInstall) {
|
|
||||||
gotoUrl(json.url, json.newTab);
|
|
||||||
return { setupPending: externalUrl || json.url.endsWith("/setup") };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip redirection only if it is an OmniInstall and redirect URL isn't of some other origin
|
|
||||||
// This allows installation of apps like Stripe to still redirect to their authentication pages.
|
|
||||||
|
|
||||||
// Check first that the URL is absolute, then check that it is of different origin from the current.
|
// Check first that the URL is absolute, then check that it is of different origin from the current.
|
||||||
if (externalUrl) {
|
if (externalUrl) {
|
||||||
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
|
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
|
||||||
gotoUrl(json.url, json.newTab);
|
gotoUrl(json.url, json.newTab);
|
||||||
return { setupPending: externalUrl };
|
return { setupPending: !json.newTab };
|
||||||
|
} else if (json.url) {
|
||||||
|
gotoUrl(json.url, json.newTab);
|
||||||
|
return {
|
||||||
|
setupPending:
|
||||||
|
json?.url?.endsWith("/setup") || json?.url?.includes("/apps/installation/event-types"),
|
||||||
|
};
|
||||||
|
} else if (returnTo) {
|
||||||
|
gotoUrl(returnTo, false);
|
||||||
|
return { setupPending: true };
|
||||||
|
} else {
|
||||||
|
return { setupPending: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { setupPending: externalUrl || json.url.endsWith("/setup") };
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
**/
|
**/
|
||||||
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
||||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||||
|
import { appKeysSchema as campsite_zod_ts } from "./campsite/zod";
|
||||||
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
|
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
|
||||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||||
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
|
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
|
||||||
@@ -50,6 +51,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
|||||||
export const appKeysSchemas = {
|
export const appKeysSchemas = {
|
||||||
alby: alby_zod_ts,
|
alby: alby_zod_ts,
|
||||||
basecamp3: basecamp3_zod_ts,
|
basecamp3: basecamp3_zod_ts,
|
||||||
|
campsite: campsite_zod_ts,
|
||||||
closecom: closecom_zod_ts,
|
closecom: closecom_zod_ts,
|
||||||
dailyvideo: dailyvideo_zod_ts,
|
dailyvideo: dailyvideo_zod_ts,
|
||||||
fathom: fathom_zod_ts,
|
fathom: fathom_zod_ts,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import basecamp3_config_json from "./basecamp3/config.json";
|
|||||||
import cal_ai_config_json from "./cal-ai/config.json";
|
import cal_ai_config_json from "./cal-ai/config.json";
|
||||||
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||||
import campfire_config_json from "./campfire/config.json";
|
import campfire_config_json from "./campfire/config.json";
|
||||||
|
import campsite_config_json from "./campsite/config.json";
|
||||||
import clic_config_json from "./clic/config.json";
|
import clic_config_json from "./clic/config.json";
|
||||||
import closecom_config_json from "./closecom/config.json";
|
import closecom_config_json from "./closecom/config.json";
|
||||||
import cron_config_json from "./cron/config.json";
|
import cron_config_json from "./cron/config.json";
|
||||||
@@ -103,6 +104,7 @@ export const appStoreMetadata = {
|
|||||||
"cal-ai": cal_ai_config_json,
|
"cal-ai": cal_ai_config_json,
|
||||||
caldavcalendar: caldavcalendar__metadata_ts,
|
caldavcalendar: caldavcalendar__metadata_ts,
|
||||||
campfire: campfire_config_json,
|
campfire: campfire_config_json,
|
||||||
|
campsite: campsite_config_json,
|
||||||
clic: clic_config_json,
|
clic: clic_config_json,
|
||||||
closecom: closecom_config_json,
|
closecom: closecom_config_json,
|
||||||
cron: cron_config_json,
|
cron: cron_config_json,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
**/
|
**/
|
||||||
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
||||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||||
|
import { appDataSchema as campsite_zod_ts } from "./campsite/zod";
|
||||||
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
|
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
|
||||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||||
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
|
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
|
||||||
@@ -50,6 +51,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
|||||||
export const appDataSchemas = {
|
export const appDataSchemas = {
|
||||||
alby: alby_zod_ts,
|
alby: alby_zod_ts,
|
||||||
basecamp3: basecamp3_zod_ts,
|
basecamp3: basecamp3_zod_ts,
|
||||||
|
campsite: campsite_zod_ts,
|
||||||
closecom: closecom_zod_ts,
|
closecom: closecom_zod_ts,
|
||||||
dailyvideo: dailyvideo_zod_ts,
|
dailyvideo: dailyvideo_zod_ts,
|
||||||
fathom: fathom_zod_ts,
|
fathom: fathom_zod_ts,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const apiHandlers = {
|
|||||||
"cal-ai": import("./cal-ai/api"),
|
"cal-ai": import("./cal-ai/api"),
|
||||||
caldavcalendar: import("./caldavcalendar/api"),
|
caldavcalendar: import("./caldavcalendar/api"),
|
||||||
campfire: import("./campfire/api"),
|
campfire: import("./campfire/api"),
|
||||||
|
campsite: import("./campsite/api"),
|
||||||
clic: import("./clic/api"),
|
clic: import("./clic/api"),
|
||||||
closecom: import("./closecom/api"),
|
closecom: import("./closecom/api"),
|
||||||
cron: import("./cron/api"),
|
cron: import("./cron/api"),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
const variant = appConfig.variant;
|
const variant = appConfig.variant;
|
||||||
const appType = appConfig.type;
|
const appType = appConfig.type;
|
||||||
const teamId = req.query.teamId ? Number(req.query.teamId) : undefined;
|
const teamId = req.query.teamId ? Number(req.query.teamId) : undefined;
|
||||||
|
const returnTo = req.query?.returnTo;
|
||||||
|
|
||||||
await checkInstalled(slug, session.user.id);
|
await checkInstalled(slug, session.user.id);
|
||||||
await createDefaultInstallation({
|
await createDefaultInstallation({
|
||||||
@@ -23,7 +24,7 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { url: getInstalledAppPath({ variant, slug }) };
|
return { url: returnTo ?? getInstalledAppPath({ variant, slug }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(getHandler);
|
export default defaultResponder(getHandler);
|
||||||
@@ -89,9 +89,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const state = decodeOAuthState(req);
|
const state = decodeOAuthState(req);
|
||||||
|
|
||||||
if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") {
|
|
||||||
return res.redirect(state.appOnboardingRedirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }));
|
res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }));
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
**/
|
**/
|
||||||
import around_config_json from "./around/config.json";
|
import around_config_json from "./around/config.json";
|
||||||
import campfire_config_json from "./campfire/config.json";
|
import campfire_config_json from "./campfire/config.json";
|
||||||
|
import campsite_config_json from "./campsite/config.json";
|
||||||
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
||||||
import demodesk_config_json from "./demodesk/config.json";
|
import demodesk_config_json from "./demodesk/config.json";
|
||||||
import discord_config_json from "./discord/config.json";
|
import discord_config_json from "./discord/config.json";
|
||||||
@@ -45,6 +46,7 @@ import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
|
|||||||
export const appStoreMetadata = {
|
export const appStoreMetadata = {
|
||||||
around: around_config_json,
|
around: around_config_json,
|
||||||
campfire: campfire_config_json,
|
campfire: campfire_config_json,
|
||||||
|
campsite: campsite_config_json,
|
||||||
dailyvideo: dailyvideo__metadata_ts,
|
dailyvideo: dailyvideo__metadata_ts,
|
||||||
demodesk: demodesk_config_json,
|
demodesk: demodesk_config_json,
|
||||||
discord: discord_config_json,
|
discord: discord_config_json,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
items:
|
||||||
|
- 1.png
|
||||||
|
- 2.png
|
||||||
|
- 3.png
|
||||||
|
---
|
||||||
|
|
||||||
|
{DESCRIPTION}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||||
|
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||||
|
|
||||||
|
const campsiteAppKeysSchema = z.object({
|
||||||
|
client_id: z.string(),
|
||||||
|
client_secret: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getCampsiteAppKeys = async () => {
|
||||||
|
const appKeys = await getAppKeysFromSlug("campsite");
|
||||||
|
return campsiteAppKeysSchema.parse(appKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest) {
|
||||||
|
// Get user
|
||||||
|
const user = req?.session?.user;
|
||||||
|
if (!user) {
|
||||||
|
return { status: 401, body: { error: "Unauthorized" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client_id } = await getCampsiteAppKeys();
|
||||||
|
const state = encodeOAuthState(req);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id,
|
||||||
|
redirect_uri: `${WEBAPP_URL}/api/integrations/campsite/callback`,
|
||||||
|
state,
|
||||||
|
scope: "read_user write_call_room",
|
||||||
|
};
|
||||||
|
const query = stringify(params);
|
||||||
|
const url = `https://auth.campsite.co/oauth/authorize?${query}`;
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultHandler({
|
||||||
|
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||||
|
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||||
|
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
|
||||||
|
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const state = decodeOAuthState(req);
|
||||||
|
const userId = req.session?.user.id;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(404).json({ message: "No user found" });
|
||||||
|
}
|
||||||
|
const { code } = req.query;
|
||||||
|
const { client_id, client_secret } = await getAppKeysFromSlug("campsite");
|
||||||
|
|
||||||
|
const result = await fetch(`https://auth.campsite.co/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri: `${WEBAPP_URL}/api/integrations/campsite/callback`,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 200) {
|
||||||
|
let errorMessage = "Something is wrong with the Campsite API";
|
||||||
|
try {
|
||||||
|
const responseBody = await result.json();
|
||||||
|
errorMessage = responseBody.error;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
res.status(400).json({ message: errorMessage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = await result.json();
|
||||||
|
if (responseBody.error) {
|
||||||
|
res.status(400).json({ message: responseBody.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With this we take care of no duplicate Campsite keys for a single user
|
||||||
|
* when creating a room using deleteMany if there is already a Campsite key
|
||||||
|
* */
|
||||||
|
await prisma.credential.deleteMany({
|
||||||
|
where: {
|
||||||
|
type: "campsite_conferencing",
|
||||||
|
userId,
|
||||||
|
appId: "campsite",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createOAuthAppCredential(
|
||||||
|
{ appId: "campsite", type: "campsite_conferencing" },
|
||||||
|
{ access_token: responseBody.access_token },
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
res.redirect(
|
||||||
|
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "campsite" })
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as add } from "./add";
|
||||||
|
export { default as callback } from "./callback";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||||
|
"name": "Campsite",
|
||||||
|
"slug": "campsite",
|
||||||
|
"type": "campsite_conferencing",
|
||||||
|
"logo": "icon.svg",
|
||||||
|
"url": "https://campsite.co",
|
||||||
|
"variant": "conferencing",
|
||||||
|
"categories": ["conferencing"],
|
||||||
|
"publisher": "Campsite",
|
||||||
|
"email": "support@campsite.co",
|
||||||
|
"appData": {
|
||||||
|
"location": {
|
||||||
|
"type": "integrations:{SLUG}_conferencing",
|
||||||
|
"label": "{TITLE}",
|
||||||
|
"linkType": "dynamic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Campsite is an all-in-one communication app that helps async teams move faster. Posts, docs, calls, and chat in one place — finally. Everything is searchable and easy to find.",
|
||||||
|
"isTemplate": false,
|
||||||
|
"__createdUsingCli": true,
|
||||||
|
"__template": "event-type-location-video-static"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * as api from "./api";
|
||||||
|
export * as lib from "./lib";
|
||||||
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user