Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
dfbcf4742f | ||
|
|
3a2a70d83a | ||
|
|
3948b8d203 | ||
|
|
6207afdf03 | ||
|
|
52c7a89785 | ||
|
|
dda4ce5338 | ||
|
|
a02747fc52 | ||
|
|
03311def07 | ||
|
|
5e382ffd21 | ||
|
|
0e0dfcdd88 | ||
|
|
e260809b08 | ||
|
|
835443a0d1 | ||
|
|
48842e72a6 | ||
|
|
ff8931f4a0 | ||
|
|
6670bbc1d7 | ||
|
|
750676f91a | ||
|
|
db60f8faed | ||
|
|
2b3cb20fdf | ||
|
|
7f1d45bb5d | ||
|
|
6632c26063 | ||
|
|
97a3189c12 | ||
|
|
d25595b546 | ||
|
|
a28eb63d61 | ||
|
|
4f944ec12d | ||
|
|
e33930e5c0 | ||
|
|
9613b9da6d | ||
|
|
ec755b142c |
@@ -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/
|
||||
# Configures the global From: header whilst sending emails.
|
||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||
EMAIL_FROM_NAME='Cal.com'
|
||||
|
||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||
# Configuration to receive emails locally (mailhog)
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
## Mandatory Tasks (DO NOT REMOVE)
|
||||
|
||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
|
||||
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
|
||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ runs:
|
||||
cache-name: cache-db
|
||||
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
|
||||
key-2: ${{ github.event.pull_request.number || github.ref }}
|
||||
key-3: ${{ github.event.pull_request.head.sha }}
|
||||
DATABASE_URL: ${{ inputs.DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
|
||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||
@@ -24,7 +25,7 @@ runs:
|
||||
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
||||
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
name: PR Labeled with ready-for-e2e
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
branches:
|
||||
- main
|
||||
- gh-actions-test-branch
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
run-e2e-jobs:
|
||||
name: Run E2E Jobs
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Check Label and Retrigger Checks
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const labelName = context.payload.label.name;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (labelName === 'ready-for-e2e') {
|
||||
console.log(`Running E2E jobs for PR #${prNumber}...`);
|
||||
|
||||
const checkRuns = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: context.payload.pull_request.head.sha,
|
||||
});
|
||||
|
||||
for (const check of checkRuns.data.check_runs) {
|
||||
console.log('check.name', check.name);
|
||||
if (check.name.includes('e2e')) {
|
||||
await github.rest.actions.reRunJob({
|
||||
owner,
|
||||
repo,
|
||||
job_id: check.id,
|
||||
});
|
||||
console.log(`Triggering job #${check.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Triggered E2E checks for PR #${prNumber}`);
|
||||
} else {
|
||||
console.log(`Label ${labelName} does not trigger re-running checks.`);
|
||||
}
|
||||
+17
-15
@@ -2,6 +2,7 @@ name: PR Update
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- gh-actions-test-branch
|
||||
@@ -105,13 +106,6 @@ jobs:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
integration-test:
|
||||
name: Tests
|
||||
needs: [changes, check-label]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/integration-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
build-api-v1:
|
||||
name: Production builds
|
||||
needs: [changes, check-label]
|
||||
@@ -133,30 +127,37 @@ jobs:
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
integration-test:
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/integration-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: e2e
|
||||
needs: [changes, check-label, build]
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: e2e-app-store
|
||||
needs: [changes, check-label, build]
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: e2e-embed
|
||||
needs: [changes, check-label, build]
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: e2e-embed-react
|
||||
needs: [changes, check-label, build]
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
@@ -169,6 +170,7 @@ jobs:
|
||||
|
||||
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]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: fail if conditional jobs failed
|
||||
|
||||
@@ -21,6 +21,8 @@ export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(sch
|
||||
export const schemaBookingGetParams = z.object({
|
||||
dateFrom: iso8601.optional(),
|
||||
dateTo: iso8601.optional(),
|
||||
order: z.enum(["asc", "desc"]).default("asc"),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
|
||||
});
|
||||
|
||||
const schemaBookingEditParams = z
|
||||
|
||||
@@ -51,6 +51,18 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
|
||||
* type: string
|
||||
* format: email
|
||||
* 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
|
||||
* tags:
|
||||
* - bookings
|
||||
@@ -177,7 +189,7 @@ export async function handler(req: NextApiRequest) {
|
||||
isOrganizationOwnerOrAdmin,
|
||||
pagination: { take, skip },
|
||||
} = req;
|
||||
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
|
||||
const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
|
||||
|
||||
const args: Prisma.BookingFindManyArgs = {};
|
||||
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);
|
||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@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.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.4": "npm:@calcom/platform-libraries@0.0.4",
|
||||
"@calcom/platform-types": "*",
|
||||
|
||||
@@ -139,7 +139,6 @@ describe("Bookings Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?filters[status]=upcoming")
|
||||
.then((response) => {
|
||||
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
|
||||
const responseBody: GetBookingsOutput = response.body;
|
||||
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 body = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-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 { 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 { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
@@ -32,7 +34,7 @@ import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
|
||||
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 {
|
||||
getAllUserBookings,
|
||||
getBookingInfo,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
handleNewRecurringBooking,
|
||||
handleInstantMeeting,
|
||||
} 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 { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -194,6 +197,27 @@ export class BookingsController {
|
||||
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")
|
||||
async createRecurringBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@@ -287,8 +311,14 @@ export class BookingsController {
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
|
||||
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
|
||||
private handleBookingErrors(
|
||||
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) {
|
||||
const httpError = err as HttpError;
|
||||
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 { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||
|
||||
import {
|
||||
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";
|
||||
|
||||
class CalendarsServiceMock {
|
||||
async getCalendars() {
|
||||
return {
|
||||
connectedCalendars: [
|
||||
{
|
||||
integration: {
|
||||
type: "google_calendar",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Platform Calendars Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -66,8 +53,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.overrideProvider(CalendarsService)
|
||||
.useClass(CalendarsServiceMock)
|
||||
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -85,6 +71,9 @@ describe("Platform Calendars Endpoints", () => {
|
||||
accessTokenSecret = tokens.accessToken;
|
||||
refreshTokenSecret = tokens.refreshToken;
|
||||
await app.init();
|
||||
jest
|
||||
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface CalendarApp {
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface CredentialSyncCalendarApp {
|
||||
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface OAuthCalendarApp extends CalendarApp {
|
||||
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
|
||||
CalendarsService,
|
||||
OutlookService,
|
||||
GoogleCalendarService,
|
||||
AppleCalendarService,
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
@@ -21,13 +22,22 @@ import {
|
||||
Headers,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
Post,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { APPS_READ } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
|
||||
import {
|
||||
SUCCESS_STATUS,
|
||||
CALENDARS,
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
APPLE_CALENDAR,
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -39,7 +49,8 @@ export class CalendarsController {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService
|
||||
private readonly googleCalendarService: GoogleCalendarService,
|
||||
private readonly appleCalendarService: AppleCalendarService
|
||||
) {}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -133,6 +144,26 @@ export class CalendarsController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Post("/:calendar/credentials")
|
||||
async syncCredentials(
|
||||
@GetUser() user: User,
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: { username: string; password: string }
|
||||
): Promise<{ status: string }> {
|
||||
const { username, password } = body;
|
||||
|
||||
switch (calendar) {
|
||||
case APPLE_CALENDAR:
|
||||
return await this.appleCalendarService.save(user.id, user.email, username, password);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@@ -143,6 +174,8 @@ export class CalendarsController {
|
||||
return await this.outlookService.check(userId);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.check(userId);
|
||||
case APPLE_CALENDAR:
|
||||
return await this.appleCalendarService.check(userId);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.14";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async save(
|
||||
userId: number,
|
||||
userEmail: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ status: string }> {
|
||||
return await this.saveCalendarCredentials(userId, userEmail, username, password);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!appleCalendarCredentials) {
|
||||
throw new BadRequestException("Credentials for apple calendar not found.");
|
||||
}
|
||||
|
||||
if (appleCalendarCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid apple calendar credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const appleCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
|
||||
);
|
||||
if (!appleCalendar) {
|
||||
throw new UnauthorizedException("Apple calendar not connected.");
|
||||
}
|
||||
if (appleCalendar.error?.message) {
|
||||
throw new UnauthorizedException(appleCalendar.error?.message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||
if (username.length <= 1 || password.length <= 1)
|
||||
throw new BadRequestException(`Username or password cannot be empty`);
|
||||
|
||||
const data = {
|
||||
type: APPLE_CALENDAR_TYPE,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ username, password }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const dav = new CalendarService({
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.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";
|
||||
|
||||
class CalendarsServiceMock {
|
||||
async getCalendars() {
|
||||
return {
|
||||
connectedCalendars: [
|
||||
{
|
||||
integration: {
|
||||
type: "google_calendar",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Platform Gcal Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.overrideProvider(CalendarsService)
|
||||
.useClass(CalendarsServiceMock)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
|
||||
accessTokenSecret = tokens.accessToken;
|
||||
refreshTokenSecret = tokens.refreshToken;
|
||||
await app.init();
|
||||
jest
|
||||
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
|
||||
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())
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer invalid_access_token`)
|
||||
.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())
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
|
||||
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())
|
||||
.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`
|
||||
)
|
||||
.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())
|
||||
.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`
|
||||
)
|
||||
.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())
|
||||
.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`
|
||||
)
|
||||
.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())
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.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);
|
||||
});
|
||||
|
||||
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())
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
|
||||
.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(
|
||||
"google_calendar",
|
||||
{},
|
||||
|
||||
@@ -17,6 +17,6 @@ import { Module } from "@nestjs/common";
|
||||
OutputSchedulesService_2024_06_11,
|
||||
],
|
||||
controllers: [SchedulesController_2024_06_11],
|
||||
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11],
|
||||
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11, OutputSchedulesService_2024_06_11],
|
||||
})
|
||||
export class SchedulesModule_2024_06_11 {}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
|
||||
export const ORG_ROLES = [
|
||||
`ORG_${MembershipRole.OWNER}`,
|
||||
`ORG_${MembershipRole.ADMIN}`,
|
||||
`ORG_${MembershipRole.MEMBER}`,
|
||||
] as const;
|
||||
export const TEAM_ROLES = [
|
||||
`TEAM_${MembershipRole.OWNER}`,
|
||||
`TEAM_${MembershipRole.ADMIN}`,
|
||||
`TEAM_${MembershipRole.MEMBER}`,
|
||||
] as const;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
export type GetTeamReturnType = Team;
|
||||
|
||||
export const GetTeam = createParamDecorator<
|
||||
keyof GetTeamReturnType | (keyof GetTeamReturnType)[],
|
||||
ExecutionContext
|
||||
>((data, ctx) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const team = request.team as GetTeamReturnType;
|
||||
|
||||
if (!team) {
|
||||
throw new Error("GetTeam decorator : Team not found");
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.reduce((prev, curr) => {
|
||||
return {
|
||||
...prev,
|
||||
[curr]: team[curr],
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return team[data];
|
||||
}
|
||||
|
||||
return team;
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
export const Roles = Reflector.createDecorator<MembershipRole[]>();
|
||||
export const Roles = Reflector.createDecorator<
|
||||
(typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
|
||||
>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
@@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
|
||||
await this.isPlatform(organizationId);
|
||||
|
||||
const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id);
|
||||
const allowedRoles = this.reflector.get(Roles, context.getHandler());
|
||||
const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler());
|
||||
|
||||
this.isMembershipAccepted(membership.accepted);
|
||||
this.isRoleAllowed(membership.role, allowedRoles);
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
type CachedData = {
|
||||
org?: Team;
|
||||
canAccess?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IsOrgGuard implements CanActivate {
|
||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
||||
constructor(
|
||||
private organizationsRepository: OrganizationsRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
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.");
|
||||
}
|
||||
|
||||
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) {
|
||||
request.organization = org;
|
||||
return true;
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
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 () => {
|
||||
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", () => {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
|
||||
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
private readonly logger = new Logger("RolesGuard Logger");
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private membershipRepository: MembershipsRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const teamId = request.params.teamId as string;
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as GetUserReturnType;
|
||||
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
|
||||
if (!user) {
|
||||
this.logger.log("User is not authenticated, denying access.");
|
||||
canAccess = false;
|
||||
}
|
||||
|
||||
// System admin can access everything
|
||||
else if (user.isSystemAdmin) {
|
||||
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
|
||||
canAccess = true;
|
||||
}
|
||||
|
||||
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
|
||||
else if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
|
||||
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
|
||||
canAccess = false;
|
||||
}
|
||||
|
||||
// Checking the role of the user within the organization
|
||||
else if (Boolean(orgId) && !Boolean(teamId)) {
|
||||
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||
if (!membership) {
|
||||
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
||||
throw new ForbiddenException(`User is not a member of the organization.`);
|
||||
}
|
||||
|
||||
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||
canAccess = hasMinimumRole({
|
||||
checkRole: `ORG_${membership.role}`,
|
||||
minimumRole: allowedRole,
|
||||
roles: ORG_ROLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Checking the role of the user within the team
|
||||
else if (Boolean(teamId) && !Boolean(orgId)) {
|
||||
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||
if (!membership) {
|
||||
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.`);
|
||||
}
|
||||
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
||||
canAccess = hasMinimumRole({
|
||||
checkRole: `TEAM_${membership.role}`,
|
||||
minimumRole: allowedRole,
|
||||
roles: TEAM_ROLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Checking the role for team and org, org is above team in term of permissions
|
||||
else if (Boolean(teamId) && Boolean(orgId)) {
|
||||
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||
|
||||
if (!orgMembership) {
|
||||
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
|
||||
throw new ForbiddenException(`User is not part of the organization.`);
|
||||
}
|
||||
|
||||
// if the role checked is a TEAM role
|
||||
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 (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
|
||||
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 user is not admin nor an owner of org, and is part of the team, then check user team membership role
|
||||
canAccess = hasMinimumRole({
|
||||
checkRole: `TEAM_${teamMembership.role}`,
|
||||
minimumRole: allowedRole,
|
||||
roles: TEAM_ROLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// if allowed role is a ORG ROLE, check org membersip role
|
||||
else if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||
canAccess = hasMinimumRole({
|
||||
checkRole: `ORG_${orgMembership.role}`,
|
||||
minimumRole: allowedRole,
|
||||
roles: ORG_ROLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
}
|
||||
}
|
||||
|
||||
type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];
|
||||
|
||||
type HasMinimumTeamRoleProp = {
|
||||
checkRole: (typeof TEAM_ROLES)[number];
|
||||
minimumRole: string;
|
||||
roles: typeof TEAM_ROLES;
|
||||
};
|
||||
|
||||
type HasMinimumOrgRoleProp = {
|
||||
checkRole: (typeof ORG_ROLES)[number];
|
||||
minimumRole: string;
|
||||
roles: typeof ORG_ROLES;
|
||||
};
|
||||
|
||||
type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;
|
||||
|
||||
export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
|
||||
const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
|
||||
const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);
|
||||
|
||||
// minimum role given does not exist
|
||||
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
|
||||
throw new Error("Invalid role");
|
||||
}
|
||||
|
||||
return checkedRoleIndex <= requiredRoleIndex;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsTeamInOrg implements CanActivate {
|
||||
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
|
||||
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
|
||||
request.team = team;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsUserInOrg implements CanActivate {
|
||||
constructor(private organizationsRepository: OrganizationsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const orgId: string = request.params.orgId;
|
||||
const userId: string = request.params.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new ForbiddenException("No user id found in request params.");
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
throw new ForbiddenException("No org id found in request params.");
|
||||
}
|
||||
|
||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
||||
|
||||
if (user) {
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -89,14 +89,11 @@ describe("ApiAuthStrategy", () => {
|
||||
|
||||
describe("authenticate with strategy", () => {
|
||||
it("should return user associated with valid access token", async () => {
|
||||
console.log("HERERERER");
|
||||
const { accessToken } = await tokensRepositoryFixture.createTokens(
|
||||
validAccessTokenUser.id,
|
||||
oAuthClient.id
|
||||
);
|
||||
|
||||
console.log("Access token", accessToken);
|
||||
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
@@ -110,9 +107,9 @@ describe("ApiAuthStrategy", () => {
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const user = await strategy.authenticate(request);
|
||||
|
||||
await expect(user.id).toEqual(validAccessTokenUser.id);
|
||||
const user = await strategy.accessTokenStrategy(accessToken);
|
||||
await expect(user).toBeDefined();
|
||||
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
|
||||
});
|
||||
|
||||
it("should return user associated with valid api key", async () => {
|
||||
@@ -133,9 +130,9 @@ describe("ApiAuthStrategy", () => {
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const user = await strategy.authenticate(request);
|
||||
|
||||
expect(user.id).toEqual(validApiKeyUser.id);
|
||||
const user = await strategy.apiKeyStrategy(keyString);
|
||||
await expect(user).toBeDefined();
|
||||
if (user) expect(user.id).toEqual(validApiKeyUser.id);
|
||||
});
|
||||
|
||||
it("should throw 401 if api key is invalid", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||
@@ -47,7 +47,7 @@ export class BillingController {
|
||||
|
||||
@Get("/:teamId/check")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Roles(["OWNER", "ADMIN", "MEMBER"])
|
||||
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
|
||||
async checkTeamBilling(
|
||||
@Param("teamId") teamId: number
|
||||
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
||||
@@ -64,7 +64,7 @@ export class BillingController {
|
||||
|
||||
@Post("/:teamId/subscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Roles(["OWNER", "ADMIN"])
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
async subscribeTeamToStripe(
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
|
||||
@@ -133,6 +133,9 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async increaseUsageByClientId(clientId: string) {
|
||||
if (this.configService.get("e2e")) {
|
||||
return void 0;
|
||||
}
|
||||
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
|
||||
if (!team.id) return Promise.resolve(); // noop resolution.
|
||||
|
||||
|
||||
@@ -18,6 +18,23 @@ export class MembershipsRepository {
|
||||
return membership;
|
||||
}
|
||||
|
||||
async findMembershipByTeamId(teamId: number, userId: number) {
|
||||
const membership = await this.dbRead.prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
async findMembershipByOrgId(orgId: number, userId: number) {
|
||||
return this.findMembershipByTeamId(orgId, userId);
|
||||
}
|
||||
|
||||
async isUserOrganizationAdmin(userId: number, organizationId: number) {
|
||||
const adminMembership = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
|
||||
+13
@@ -13,6 +13,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -62,6 +63,8 @@ describe("OAuth Clients Endpoints", () => {
|
||||
let usersFixtures: UserRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let teamFixtures: TeamRepositoryFixture;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let app: INestApplication;
|
||||
@@ -80,11 +83,14 @@ describe("OAuth Clients Endpoints", () => {
|
||||
usersFixtures = new UserRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
teamFixtures = new TeamRepositoryFixture(moduleRef);
|
||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||
|
||||
user = await usersFixtures.create({
|
||||
email: userEmail,
|
||||
});
|
||||
org = await teamFixtures.create({
|
||||
name: "apiOrg",
|
||||
isOrganization: true,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
orgAutoAcceptEmail: "api.com",
|
||||
@@ -94,6 +100,7 @@ describe("OAuth Clients Endpoints", () => {
|
||||
isPlatform: false,
|
||||
});
|
||||
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
await platformBillingRepositoryFixture.create(org.id);
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
@@ -114,6 +121,8 @@ describe("OAuth Clients Endpoints", () => {
|
||||
let usersFixtures: UserRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let teamFixtures: TeamRepositoryFixture;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let app: INestApplication;
|
||||
@@ -132,11 +141,14 @@ describe("OAuth Clients Endpoints", () => {
|
||||
usersFixtures = new UserRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
teamFixtures = new TeamRepositoryFixture(moduleRef);
|
||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||
|
||||
user = await usersFixtures.create({
|
||||
email: userEmail,
|
||||
});
|
||||
org = await teamFixtures.create({
|
||||
name: "apiOrg",
|
||||
isOrganization: true,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
orgAutoAcceptEmail: "api.com",
|
||||
@@ -145,6 +157,7 @@ describe("OAuth Clients Endpoints", () => {
|
||||
},
|
||||
isPlatform: true,
|
||||
});
|
||||
await platformBillingRepositoryFixture.create(org.id);
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
@@ -64,7 +64,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
@DocsCreatedResponse({
|
||||
description: "Create an OAuth client",
|
||||
@@ -96,7 +96,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
|
||||
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
|
||||
@@ -107,7 +107,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Get("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
|
||||
const client = await this.oauthClientRepository.getOAuthClient(clientId);
|
||||
@@ -119,7 +119,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Get("/:clientId/managed-users")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async getOAuthClientManagedUsersById(
|
||||
@Param("clientId") clientId: string,
|
||||
@@ -137,7 +137,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Patch("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async updateOAuthClient(
|
||||
@Param("clientId") clientId: string,
|
||||
@@ -150,7 +150,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Delete("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
|
||||
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
|
||||
|
||||
+3
-3
@@ -113,7 +113,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
const REDIRECT_STATUS = 302;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/oauth/${oAuthClient.id}/authorize`)
|
||||
.post(`/v2/oauth/${oAuthClient.id}/authorize`)
|
||||
.send(body)
|
||||
.expect(REDIRECT_STATUS);
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/oauth/${oAuthClient.id}/exchange`)
|
||||
.post(`/v2/oauth/${oAuthClient.id}/exchange`)
|
||||
.set("Authorization", authorizationToken)
|
||||
.send(body)
|
||||
.expect(200);
|
||||
@@ -153,7 +153,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/oauth/${oAuthClient.id}/refresh`)
|
||||
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
|
||||
.set("x-cal-secret-key", secretKey)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.15";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
@@ -36,17 +36,17 @@ export class OAuthClientUsersService {
|
||||
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
user = (
|
||||
await createNewUsersConnectToOrgIfExists({
|
||||
usernamesOrEmails: [email],
|
||||
input: {
|
||||
teamId: organizationId,
|
||||
role: "MEMBER",
|
||||
usernameOrEmail: [email],
|
||||
isOrg: true,
|
||||
language: "en",
|
||||
},
|
||||
invitations: [
|
||||
{
|
||||
usernameOrEmail: email,
|
||||
role: "MEMBER",
|
||||
},
|
||||
],
|
||||
teamId: organizationId,
|
||||
isOrg: true,
|
||||
parentId: null,
|
||||
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
|
||||
connectionInfoMap: {
|
||||
orgConnectInfoByUsernameOrEmail: {
|
||||
[email]: {
|
||||
orgId: organizationId,
|
||||
autoAccept: true,
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("User Authentication", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let org: Team;
|
||||
|
||||
const userEmail = "org-teams-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get all the teams of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsController {
|
||||
@Get()
|
||||
async getAllTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetOrg() organization: Team,
|
||||
@GetOrg("name") orgName: string
|
||||
): Promise<ApiResponse<Team[]>> {
|
||||
console.log(orgId, organization, orgName);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Body,
|
||||
Patch,
|
||||
Delete,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
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 {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
CreateScheduleOutput_2024_06_11,
|
||||
DeleteScheduleOutput_2024_06_11,
|
||||
GetScheduleOutput_2024_06_11,
|
||||
GetSchedulesOutput_2024_06_11,
|
||||
UpdateScheduleInput_2024_06_11,
|
||||
UpdateScheduleOutput_2024_06_11,
|
||||
} 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({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@DocsTags("Organizations Schedules")
|
||||
export class OrganizationsSchedulesController {
|
||||
constructor(
|
||||
private schedulesService: SchedulesService_2024_06_11,
|
||||
private organizationScheduleService: OrganizationsSchedulesService
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@Get("/schedules")
|
||||
async getOrganizationSchedules(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const schedules = await this.organizationScheduleService.getOrganizationSchedules(orgId, skip, take);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Post("/users/:userId/schedules")
|
||||
async createUserSchedule(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_06_11
|
||||
): Promise<CreateScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Get("/users/:userId/schedules/:scheduleId")
|
||||
async getUserSchedule(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Get("/users/:userId/schedules")
|
||||
async getUserSchedules(
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Patch("/users/:userId/schedules/:scheduleId")
|
||||
async updateUserSchedule(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("scheduleId", ParseIntPipe) scheduleId: number,
|
||||
@Body() bodySchedule: UpdateScheduleInput_2024_06_11
|
||||
): Promise<UpdateScheduleOutput_2024_06_11> {
|
||||
const updatedSchedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Delete("/users/:userId/schedules/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteUserSchedule(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("scheduleId", ParseIntPipe) scheduleId: number
|
||||
): Promise<DeleteScheduleOutput_2024_06_11> {
|
||||
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
CreateScheduleOutput_2024_06_11,
|
||||
GetScheduleOutput_2024_06_11,
|
||||
GetSchedulesOutput_2024_06_11,
|
||||
ScheduleAvailabilityInput_2024_06_11,
|
||||
ScheduleOutput_2024_06_11,
|
||||
UpdateScheduleInput_2024_06_11,
|
||||
UpdateScheduleOutput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
import { User, Team, Membership, Profile } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Schedules Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
|
||||
const userEmail = "mr-robot@schedules-api.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Ecorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not be able to create schedule for org user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
|
||||
.send({
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not be able to get org schedules", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/schedules`).expect(403);
|
||||
});
|
||||
|
||||
it("should mot be able to get user schedules", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User has required role", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const userEmail = "mr-robot@schedules-api.com";
|
||||
const username = "mr-robot";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let profile: Profile;
|
||||
let membership: Membership;
|
||||
|
||||
let createdSchedule: ScheduleOutput_2024_06_11;
|
||||
|
||||
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
|
||||
{
|
||||
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Ecorp",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
profile = await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: username,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create schedule for org user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
|
||||
.send(createScheduleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
createdSchedule = response.body.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
|
||||
|
||||
const scheduleOwner = createdSchedule.ownerId
|
||||
? await userRepositoryFixture.get(createdSchedule.ownerId)
|
||||
: null;
|
||||
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
|
||||
});
|
||||
});
|
||||
|
||||
function outputScheduleMatchesExpected(
|
||||
outputSchedule: ScheduleOutput_2024_06_11 | null,
|
||||
expected: CreateScheduleInput_2024_06_11 & {
|
||||
availability: CreateScheduleInput_2024_06_11["availability"];
|
||||
} & {
|
||||
overrides: CreateScheduleInput_2024_06_11["overrides"];
|
||||
},
|
||||
expectedAvailabilityLength: number
|
||||
) {
|
||||
expect(outputSchedule).toBeTruthy();
|
||||
expect(outputSchedule?.name).toEqual(expected.name);
|
||||
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
|
||||
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
|
||||
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
|
||||
|
||||
const outputScheduleAvailability = outputSchedule?.availability[0];
|
||||
expect(outputScheduleAvailability).toBeDefined();
|
||||
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
|
||||
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
|
||||
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
|
||||
|
||||
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
|
||||
}
|
||||
|
||||
it("should get org schedules", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/schedules`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const schedules = response.body.data;
|
||||
expect(schedules.length).toEqual(1);
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get org user schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const schedule = response.body.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
outputScheduleMatchesExpected(schedule, expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get user schedules", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const schedules = response.body.data;
|
||||
expect(schedules.length).toEqual(1);
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update user schedule name", async () => {
|
||||
const newScheduleName = "updated-schedule-name";
|
||||
|
||||
const body: UpdateScheduleInput_2024_06_11 = {
|
||||
name: newScheduleName,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
const responseSchedule = responseData.data;
|
||||
|
||||
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
|
||||
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
|
||||
|
||||
createdSchedule = responseSchedule;
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete user schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await profileRepositoryFixture.delete(profile.id);
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let team2: Team;
|
||||
let teamCreatedViaApi: Team;
|
||||
|
||||
const userEmail = "org-admin-teams-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
name: "Test org team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
team2 = await teamsRepositoryFixture.create({
|
||||
name: "Test org team 2",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get all the teams of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(team.id);
|
||||
expect(responseBody.data[1].id).toEqual(team2.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all the teams of the org paginated", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(team2.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if org does not exist", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/120494059/teams`).expect(403);
|
||||
});
|
||||
|
||||
it("should get the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(team.id);
|
||||
expect(responseBody.data.parentId).toEqual(team.parentId);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: "Team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi = responseBody.data;
|
||||
expect(teamCreatedViaApi.name).toEqual("Team created via API");
|
||||
expect(teamCreatedViaApi.parentId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
|
||||
.send({
|
||||
name: "Team created via API Updated",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi = responseBody.data;
|
||||
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
|
||||
expect(teamCreatedViaApi.parentId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the team of the org we created via api", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(teamCreatedViaApi.id);
|
||||
expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get the team of the org we just deleted", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should fail if the team does not exist", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("User Authentication - User is Org Member", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let team2: Team;
|
||||
|
||||
const userEmail = "org-member-teams-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
name: "Test org team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
team2 = await teamsRepositoryFixture.create({
|
||||
name: "Test org team 2",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should deny get all the teams of the org", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
|
||||
});
|
||||
|
||||
it("should deny get all the teams of the org paginated", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
|
||||
});
|
||||
|
||||
it("should deny get the team of the org", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403);
|
||||
});
|
||||
|
||||
it("should deny create the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: "Team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny update the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
|
||||
.send({
|
||||
name: "Team created via API Updated",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny delete the team of the org we created via api", async () => {
|
||||
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("User Authentication - User is Team Owner", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let team2: Team;
|
||||
|
||||
const userEmail = "org-member-teams-owner-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
name: "Test org team",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
team2 = await teamsRepositoryFixture.create({
|
||||
name: "Test org team 2",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: team2.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should deny get all the teams of the org", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
|
||||
});
|
||||
|
||||
it("should deny get all the teams of the org paginated", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
|
||||
});
|
||||
|
||||
it("should get the team of the org for which the user is team owner", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200);
|
||||
});
|
||||
|
||||
it("should deny create the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: "Team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny update the team of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
|
||||
.send({
|
||||
name: "Team created via API Updated",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should deny delete the team of the org we created via api", async () => {
|
||||
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { OrgTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiResponse, SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsController {
|
||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards()
|
||||
@Roles("ORG_ADMIN")
|
||||
async getAllTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<ApiResponse<OrgTeamOutputDto[]>> {
|
||||
const { skip, take } = queryParams;
|
||||
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Get("/:teamId")
|
||||
async getTeam(@GetTeam() team: Team): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@Delete("/:teamId")
|
||||
async deleteTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@Patch("/:teamId")
|
||||
async updateTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards()
|
||||
@Roles("ORG_ADMIN")
|
||||
async createTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
): Promise<ApiResponse<OrgTeamOutputDto>> {
|
||||
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
export class CreateOrgTeamDto {
|
||||
@IsString()
|
||||
@Length(1)
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly calVideoLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly appLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly appIconLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly bio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly hideBranding?: boolean = false;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly isPrivate?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly hideBookATeamMember?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly brandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly darkBrandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
readonly bannerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly timeFormat?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly timeZone?: string = "Europe/London";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly weekStart?: string = "Sunday";
|
||||
}
|
||||
@@ -1,14 +1,31 @@
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
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 { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.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 { OrganizationsService } from "@/modules/organizations/services/organizations.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule],
|
||||
providers: [OrganizationsRepository, OrganizationsService],
|
||||
exports: [OrganizationsService, OrganizationsRepository],
|
||||
controllers: [OrganizationsTeamsController],
|
||||
imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule, RedisModule],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsService,
|
||||
OrganizationsTeamsService,
|
||||
MembershipsRepository,
|
||||
OrganizationsSchedulesService,
|
||||
OrganizationSchedulesRepository,
|
||||
],
|
||||
exports: [OrganizationsService, OrganizationsRepository, OrganizationsTeamsRepository],
|
||||
controllers: [OrganizationsTeamsController, OrganizationsSchedulesController],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -70,4 +70,12 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
async findOrgUser(organizationId: number, userId: number) {
|
||||
return this.dbRead.prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
export class OrgTeamOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly id!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
readonly parentId?: number;
|
||||
|
||||
@IsString()
|
||||
@Length(1)
|
||||
@Expose()
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly calVideoLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly appLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly appIconLogo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly bio?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly hideBranding?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly isOrganization?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly isPrivate?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly hideBookATeamMember?: boolean = false;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly metadata?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly brandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly darkBrandColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@Expose()
|
||||
readonly bannerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly timeFormat?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly timeZone?: string = "Europe/London";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly weekStart?: string = "Sunday";
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationSchedulesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
|
||||
return this.dbRead.prisma.schedule.findMany({
|
||||
where: {
|
||||
userId: {
|
||||
in: userIds,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRepository {
|
||||
constructor(
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async findOrgTeam(organizationId: number, teamId: number) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
isOrganization: false,
|
||||
parentId: organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOrgTeam(organizationId: number, teamId: number) {
|
||||
return this.dbRead.prisma.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
isOrganization: false,
|
||||
parentId: organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.create({
|
||||
data: { ...data, parentId: organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
|
||||
return this.dbRead.prisma.team.update({
|
||||
data: { ...data },
|
||||
where: { id: teamId, parentId: organizationId, isOrganization: false },
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeamsPaginated(organizationId: number, skip: number, take: number) {
|
||||
return this.dbRead.prisma.team.findMany({
|
||||
where: {
|
||||
parentId: organizationId,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsSchedulesService {
|
||||
constructor(
|
||||
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async getOrganizationSchedules(organizationId: number, skip = 0, take = 250) {
|
||||
const users = await this.usersRepository.getOrganizationUsers(organizationId);
|
||||
const usersIds = users.map((user) => user.id);
|
||||
|
||||
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
|
||||
|
||||
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
|
||||
}
|
||||
|
||||
return responseSchedules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsService {
|
||||
constructor(private readonly organizationsTeamRepository: OrganizationsTeamsRepository) {}
|
||||
|
||||
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
|
||||
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
|
||||
return teams;
|
||||
}
|
||||
|
||||
async deleteOrgTeam(organizationId: number, teamId: number) {
|
||||
const team = await this.organizationsTeamRepository.deleteOrgTeam(organizationId, teamId);
|
||||
return team;
|
||||
}
|
||||
|
||||
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
|
||||
const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
|
||||
return team;
|
||||
}
|
||||
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
|
||||
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data);
|
||||
return team;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
|
||||
@@ -31,5 +32,6 @@ export class UpdateManagedUserInput {
|
||||
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
@CapitalizeTimeZone()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,18 @@ export class UsersRepository {
|
||||
|
||||
return user?.defaultScheduleId;
|
||||
}
|
||||
|
||||
async getOrganizationUsers(organizationId: number) {
|
||||
const profiles = await this.dbRead.prisma.profile.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
return profiles.map((profile) => profile.user);
|
||||
}
|
||||
}
|
||||
|
||||
function capitalizeTimezone(timezone: string) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,26 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export class ProfileRepositoryFixture {
|
||||
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 get(profileId: number) {
|
||||
return this.primaReadClient.profile.findFirst({ where: { id: profileId } });
|
||||
}
|
||||
|
||||
async create(data: Prisma.ProfileCreateInput) {
|
||||
return this.prismaWriteClient.profile.create({ data });
|
||||
}
|
||||
|
||||
async delete(profileId: number) {
|
||||
return this.prismaWriteClient.profile.delete({ where: { id: profileId } });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
public/app-store
|
||||
|
||||
certificates
|
||||
@@ -1,17 +1,12 @@
|
||||
import NotFoundPage from "@pages/404";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
import React from "react";
|
||||
|
||||
import { getTranslations } from "@server/lib/getTranslations";
|
||||
|
||||
const getData = async (context: GetStaticPropsContext) => {
|
||||
const i18n = await getTranslations(context);
|
||||
|
||||
return {
|
||||
i18n,
|
||||
};
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div data-testid="404-page">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>Sorry, the page you are looking for does not exist.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default WithLayout({ getLayout: null, getData, Page: NotFoundPage });
|
||||
export default NotFound;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
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 { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -22,7 +26,6 @@ export type AppPageProps = {
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
slug: string;
|
||||
dirName: string | undefined;
|
||||
variant: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
@@ -70,9 +73,9 @@ export const AppPage = ({
|
||||
dependencies,
|
||||
concurrentMeetings,
|
||||
paid,
|
||||
dirName,
|
||||
}: AppPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
@@ -80,13 +83,49 @@ export const AppPage = ({
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
setIsLoading(false);
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (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", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
@@ -220,23 +259,12 @@ export const AppPage = ({
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: mutation.isPending,
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
multiInstall
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
dirName={dirName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -260,22 +288,13 @@ export const AppPage = ({
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
handleAppInstall();
|
||||
},
|
||||
loading: mutation.isPending,
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
dirName={dirName}
|
||||
{...props}
|
||||
/>
|
||||
<InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...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 type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppFrontendPayload } from "@calcom/types/App";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
export const InstallAppButtonChild = ({
|
||||
userAdminTeams,
|
||||
addAppMutationInput,
|
||||
appCategories,
|
||||
multiInstall,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
dirName,
|
||||
paid,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
addAppMutationInput: { type: AppFrontendPayload["type"]; variant: string; slug: string };
|
||||
appCategories: string[];
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
concurrentMeetings?: boolean;
|
||||
paid?: AppFrontendPayload["paid"];
|
||||
dirName: string | undefined;
|
||||
} & ButtonProps) => {
|
||||
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 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
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
@@ -79,7 +24,6 @@ export const InstallAppButtonChild = ({
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
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 (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
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>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
size="base"
|
||||
{...props}>
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FC } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
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 type { Team, User } from "@calcom/prisma/client";
|
||||
import { Avatar, StepCard } from "@calcom/ui";
|
||||
@@ -31,7 +31,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
className={classNames(
|
||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
|
||||
(alreadyInstalled || loading) && "cursor-not-allowed",
|
||||
selected && "bg-muted animate-pulse"
|
||||
selected && loading && "bg-muted animate-pulse"
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
@@ -42,7 +42,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
}}>
|
||||
<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"
|
||||
/>
|
||||
<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 = {
|
||||
teams: TeamsProp;
|
||||
teams?: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
onSelect: (id?: number) => void;
|
||||
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();
|
||||
return (
|
||||
<StepCard>
|
||||
@@ -80,17 +87,18 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
|
||||
onClick={() => onSelect()}
|
||||
loading={loading}
|
||||
/>
|
||||
{teams.map((team) => (
|
||||
<AccountSelector
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logoUrl ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
{installableOnTeams &&
|
||||
teams?.map((team) => (
|
||||
<AccountSelector
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logoUrl ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
||||
import { X } from "lucide-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 { useFieldArray, useFormContext } 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 { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
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 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>;
|
||||
locations: LocationObject[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
seatsPerTimeSlot: number | null;
|
||||
};
|
||||
|
||||
type ConfigureStepCardProps = {
|
||||
export type ConfigureStepCardProps = {
|
||||
slug: string;
|
||||
userName: string;
|
||||
categories: AppCategories[];
|
||||
credentialId?: number;
|
||||
loading?: boolean;
|
||||
isConferencing: boolean;
|
||||
formPortalRef: React.RefObject<HTMLDivElement>;
|
||||
eventTypes: TEventType[] | undefined;
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -35,51 +43,39 @@ type ConfigureStepCardProps = {
|
||||
|
||||
type EventTypeAppSettingsFormProps = Pick<
|
||||
ConfigureStepCardProps,
|
||||
"slug" | "userName" | "categories" | "credentialId" | "loading"
|
||||
"slug" | "userName" | "categories" | "credentialId" | "loading" | "isConferencing"
|
||||
> & {
|
||||
eventType: TEventType;
|
||||
handleDelete: () => void;
|
||||
onSubmit: (values: z.infer<typeof EventTypeMetaDataSchema>) => void;
|
||||
};
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EventTypeAppSettings
|
||||
slug={slug}
|
||||
eventType={eventType}
|
||||
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
|
||||
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
|
||||
/>
|
||||
);
|
||||
onSubmit: ({
|
||||
locations,
|
||||
bookingFields,
|
||||
metadata,
|
||||
}: {
|
||||
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
bookingFields?: z.infer<typeof eventTypeBookingFields>;
|
||||
locations?: LocationObject[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
||||
function EventTypeAppSettingsForm(props, ref) {
|
||||
const { handleDelete, onSubmit, eventType, loading } = props;
|
||||
const { handleDelete, onSubmit, eventType, loading, isConferencing } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const formMethods = useForm<TFormType>({
|
||||
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 (
|
||||
@@ -87,8 +83,10 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
|
||||
form={formMethods}
|
||||
id={`eventtype-${eventType.id}`}
|
||||
handleSubmit={() => {
|
||||
const data = formMethods.getValues("metadata");
|
||||
onSubmit(data);
|
||||
const metadata = formMethods.getValues("metadata");
|
||||
const locations = formMethods.getValues("locations");
|
||||
const bookingFields = formMethods.getValues("bookingFields");
|
||||
onSubmit({ metadata, locations, bookingFields });
|
||||
}}>
|
||||
<div>
|
||||
<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}
|
||||
</small>
|
||||
</div>
|
||||
<EventTypeAppSettingsWrapper {...props} />
|
||||
{isConferencing ? (
|
||||
<EventTypeConferencingAppSettings {...props} />
|
||||
) : (
|
||||
<EventTypeAppSettingsWrapper {...props} />
|
||||
)}
|
||||
<X
|
||||
data-testid={`remove-event-type-${eventType.id}`}
|
||||
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
|
||||
onClick={() => !loading && handleDelete()}
|
||||
/>
|
||||
<button type="submit" className="hidden" ref={ref}>
|
||||
<button type="submit" className="hidden" form={`eventtype-${eventType.id}`} ref={ref}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@@ -148,6 +150,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
if (!fields.some((field) => field.selected)) {
|
||||
setConfigureStep(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fields]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,7 +179,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
update(index, { ...field, selected: false, metadata: eventMetadataDb });
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
update(index, { ...field, metadata: data });
|
||||
update(index, { ...field, ...data });
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
|
||||
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
|
||||
import {
|
||||
@@ -31,20 +31,20 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Icon,
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
@@ -691,11 +691,9 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (data) => {
|
||||
showToast(
|
||||
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
|
||||
x: name || email,
|
||||
}),
|
||||
t("messageKey" in data && data.messageKey ? data.messageKey : data.message, { x: name || email }),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
@@ -804,8 +802,8 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
});
|
||||
const { t } = useLocale();
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("no_show_updated"), "success");
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("messageKey" in data && data.messageKey ? data.messageKey : data.message), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
@@ -884,6 +882,69 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
</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 = ({
|
||||
attendees,
|
||||
@@ -916,7 +977,11 @@ const DisplayAttendees = ({
|
||||
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</p>
|
||||
))}>
|
||||
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
|
||||
{isBookingInPast ? (
|
||||
<GroupedAttendees attendees={attendees} bookingUid={bookingUid} />
|
||||
) : (
|
||||
<GroupedGuests guests={attendees} />
|
||||
)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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 { 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 { 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 { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
@@ -23,54 +18,12 @@ import {
|
||||
SettingsToggle,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Icon,
|
||||
Editor,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
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";
|
||||
|
||||
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 };
|
||||
};
|
||||
import Locations from "@components/eventtype/Locations";
|
||||
|
||||
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
@@ -106,25 +59,13 @@ export const EventSetupTab = (
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType, team, destinationCalendar } = props;
|
||||
const { eventType, team } = props;
|
||||
const [multipleDuration, setMultipleDuration] = useState(
|
||||
formMethods.getValues("metadata")?.multipleDuration
|
||||
);
|
||||
const orgBranding = useOrgBranding();
|
||||
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 = [
|
||||
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||
].map((mins) => ({
|
||||
@@ -144,327 +85,6 @@ export const EventSetupTab = (
|
||||
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||
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 descriptionLockedProps = shouldLockDisableProps("description");
|
||||
@@ -626,7 +246,19 @@ export const EventSetupTab = (
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Webhook } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
@@ -15,7 +15,17 @@ import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, EmptyScreen, SettingsToggle, Dialog, DialogContent, showToast } from "@calcom/ui";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
SettingsToggle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
showToast,
|
||||
TextField,
|
||||
Label,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type InstantEventControllerProps = {
|
||||
eventType: EventTypeSetup;
|
||||
@@ -85,7 +95,33 @@ export default function InstantEventController({
|
||||
}
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{instantEventState && <InstantMeetingWebhooks eventType={eventType} />}
|
||||
{instantEventState && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Controller
|
||||
name="instantMeetingExpiryTimeOffsetInSeconds"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label>{t("set_instant_meeting_expiry_time_offset_description")}</Label>
|
||||
<TextField
|
||||
required
|
||||
name="instantMeetingExpiryTimeOffsetInSeconds"
|
||||
labelSrOnly
|
||||
type="number"
|
||||
defaultValue={value}
|
||||
min={10}
|
||||
containerClassName="max-w-80"
|
||||
addOnSuffix={<>{t("seconds")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Math.abs(Number(e.target.value)));
|
||||
}}
|
||||
data-testid="instant-meeting-expiry-time-offset"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<InstantMeetingWebhooks eventType={eventType} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
@@ -213,9 +249,6 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-default mb-4 text-sm font-normal">
|
||||
{t("warning_payment_instant_meeting_event")}
|
||||
</p>
|
||||
<EmptyScreen
|
||||
Icon="webhook"
|
||||
headline={t("create_your_first_webhook")}
|
||||
|
||||
@@ -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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Badge, Button, Icon } from "@calcom/ui";
|
||||
|
||||
interface IAppConnectionItem {
|
||||
title: string;
|
||||
@@ -9,26 +14,74 @@ interface IAppConnectionItem {
|
||||
logo: string;
|
||||
type: App["type"];
|
||||
installed?: boolean;
|
||||
isDefault?: boolean;
|
||||
defaultInstall?: boolean;
|
||||
slug?: string;
|
||||
dependencyData?: TDependencyData;
|
||||
}
|
||||
|
||||
const AppConnectionItem = (props: IAppConnectionItem) => {
|
||||
const { title, logo, type, installed } = props;
|
||||
const { title, logo, type, installed, isDefault, defaultInstall, slug } = props;
|
||||
const { t } = useLocale();
|
||||
const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation();
|
||||
const dependency = props.dependencyData?.find((data) => !data.installed);
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between p-5">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={logo} alt={title} className="h-8 w-8" />
|
||||
<p className="text-sm font-bold">{title}</p>
|
||||
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||
</div>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={type}
|
||||
options={{
|
||||
onSuccess: () => {
|
||||
if (defaultInstall && slug) {
|
||||
setDefaultConferencingApp.mutate({ slug });
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={(buttonProps) => (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
color="secondary"
|
||||
disabled={installed}
|
||||
disabled={installed || !!dependency}
|
||||
type="button"
|
||||
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) => {
|
||||
// Save cookie key to return url step
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Icon, List } from "@calcom/ui";
|
||||
|
||||
import { AppConnectionItem } from "../components/AppConnectionItem";
|
||||
@@ -17,12 +19,16 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
});
|
||||
const { data } = useMeQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
const metadata = userMetadata.parse(data?.metadata);
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
|
||||
return (
|
||||
<>
|
||||
{!isPending && (
|
||||
@@ -36,9 +42,15 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={
|
||||
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import classNames from "classnames";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { debounce, noop } from "lodash";
|
||||
import { noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -71,17 +72,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const isCurrentUsernamePremium =
|
||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
||||
const debouncedApiCall = useMemo(
|
||||
() =>
|
||||
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]
|
||||
);
|
||||
// debounce the username input, set the delay to 600ms to be consistent with signup form
|
||||
const debouncedUsername = useDebounce(inputUsernameValue, 600);
|
||||
|
||||
useEffect(() => {
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputUsernameValue) {
|
||||
debouncedApiCall.cancel();
|
||||
return;
|
||||
async function checkUsername(username: string | undefined) {
|
||||
if (!username) {
|
||||
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({
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import classNames from "classnames";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { debounce, noop } from "lodash";
|
||||
import { noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -41,31 +42,28 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
|
||||
const debouncedApiCall = useMemo(
|
||||
() =>
|
||||
debounce(async (username) => {
|
||||
// TODO: Support orgSlug
|
||||
// debounce the username input, set the delay to 600ms to be consistent with signup form
|
||||
const debouncedUsername = useDebounce(inputUsernameValue, 600);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkUsername(username: string | undefined) {
|
||||
if (!username) {
|
||||
setUsernameIsAvailable(false);
|
||||
setMarkAsError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUsername !== username) {
|
||||
const { data } = await fetchUsername(username, null);
|
||||
setMarkAsError(!data.available);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputUsernameValue) {
|
||||
debouncedApiCall.cancel();
|
||||
setUsernameIsAvailable(false);
|
||||
setMarkAsError(false);
|
||||
return;
|
||||
} else {
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUsername !== inputUsernameValue) {
|
||||
debouncedApiCall(inputUsernameValue);
|
||||
} else {
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}, [inputUsernameValue, debouncedApiCall, currentUsername]);
|
||||
checkUsername(debouncedUsername);
|
||||
}, [debouncedUsername, currentUsername]);
|
||||
|
||||
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -39,13 +39,17 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
Option: (props) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
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>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
@@ -17,7 +17,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
let appStore, userAdminTeams: UserAdminTeams;
|
||||
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);
|
||||
} else {
|
||||
appStore = await getAppRegistry();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -61,10 +62,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
select: workflowSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -112,6 +112,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
eventSlug: slug,
|
||||
isTeamEvent,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
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(() => {
|
||||
if (noShow) {
|
||||
noShowMutation.mutate({ bookingUid: bookingInfo.uid });
|
||||
noShowMutation.mutate({ bookingUid: bookingInfo.uid, noShowHost: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -296,7 +296,7 @@ export default function Success(props: PageProps) {
|
||||
function getTitle(): string {
|
||||
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
||||
const titlePrefix = isRoundRobin ? "round_robin_" : "";
|
||||
if (isCancelled) {
|
||||
if (isCancelled || isBookingInPast) {
|
||||
return "";
|
||||
}
|
||||
if (needsConfirmation) {
|
||||
@@ -340,6 +340,8 @@ export default function Success(props: PageProps) {
|
||||
|
||||
const providerName = guessEventLocationType(location)?.label;
|
||||
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
|
||||
const isBookingInPast = new Date(bookingInfo.endTime) < new Date();
|
||||
const isReschedulable = !isCancelled && !isBookingInPast;
|
||||
|
||||
const bookingCancelledEventProps = {
|
||||
booking: bookingInfo,
|
||||
@@ -412,7 +414,7 @@ export default function Success(props: PageProps) {
|
||||
imageSrc={`${bookingInfo.user.avatarUrl}`}
|
||||
/>
|
||||
)}
|
||||
{giphyImage && !needsConfirmation && !isCancelled && (
|
||||
{giphyImage && !needsConfirmation && isReschedulable && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<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",
|
||||
isRoundRobin &&
|
||||
"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 && !isCancelled && needsConfirmation ? "bg-subtle" : "",
|
||||
isCancelled ? "bg-error" : ""
|
||||
!giphyImage && isReschedulable && !needsConfirmation ? "bg-success" : "",
|
||||
!giphyImage && isReschedulable && needsConfirmation ? "bg-subtle" : "",
|
||||
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" />
|
||||
)}
|
||||
{needsConfirmation && !isCancelled && (
|
||||
{needsConfirmation && isReschedulable && (
|
||||
<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 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"
|
||||
data-testid={isCancelled ? "cancelled-headline" : ""}
|
||||
id="modal-headline">
|
||||
{needsConfirmation && !isCancelled
|
||||
{needsConfirmation && isReschedulable
|
||||
? props.recurringBookings
|
||||
? t("booking_submitted_recurring")
|
||||
: t("booking_submitted")
|
||||
@@ -447,6 +451,8 @@ export default function Success(props: PageProps) {
|
||||
? seatReferenceUid
|
||||
? t("no_longer_attending")
|
||||
: t("event_cancelled")
|
||||
: isBookingInPast
|
||||
? t("event_expired")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
@@ -650,7 +656,7 @@ export default function Success(props: PageProps) {
|
||||
)}
|
||||
{!requiresLoginToUpdate &&
|
||||
(!needsConfirmation || !userIsOwner) &&
|
||||
!isCancelled &&
|
||||
isReschedulable &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
<hr className="border-subtle mb-8" />
|
||||
@@ -708,7 +714,7 @@ export default function Success(props: PageProps) {
|
||||
{userIsOwner &&
|
||||
!needsConfirmation &&
|
||||
!isCancellationMode &&
|
||||
!isCancelled &&
|
||||
isReschedulable &&
|
||||
!!calculatedDuration && (
|
||||
<>
|
||||
<hr className="border-subtle mt-8" />
|
||||
|
||||
@@ -164,7 +164,7 @@ const Item = ({
|
||||
</small>
|
||||
) : null}
|
||||
{readOnly && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
|
||||
{t("readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -195,7 +195,7 @@ const Item = ({
|
||||
</small>
|
||||
) : null}
|
||||
{readOnly && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
|
||||
{t("readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -513,7 +513,8 @@ export const EventTypeList = ({
|
||||
</DropdownItem>
|
||||
</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">
|
||||
<DropdownItem
|
||||
@@ -541,25 +542,24 @@ export const EventTypeList = ({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 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) &&
|
||||
!isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
@@ -629,7 +629,7 @@ export const EventTypeList = ({
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isManagedEventType && !isChildrenManagedEventType && (
|
||||
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
@@ -640,24 +640,23 @@ export const EventTypeList = ({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 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) &&
|
||||
!isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!readOnly && !isChildrenManagedEventType && (
|
||||
<>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!isManagedEventType && (
|
||||
<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 { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import dynamic from "next/dynamic";
|
||||
// eslint-disable-next-line @calcom/eslint/deprecated-imports-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 { getEventLocationType } from "@calcom/app-store/locations";
|
||||
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 { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||
@@ -24,7 +26,6 @@ import { HttpError } from "@calcom/lib/http-error";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -144,7 +145,70 @@ const querySchema = z.object({
|
||||
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||
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 utils = trpc.useUtils();
|
||||
const telemetry = useTelemetry();
|
||||
@@ -251,6 +315,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
destinationCalendar: eventType.destinationCalendar,
|
||||
recurringEvent: eventType.recurringEvent || null,
|
||||
isInstantEvent: eventType.isInstantEvent,
|
||||
instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds,
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
@@ -325,69 +390,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
bookingFields: eventTypeBookingFields,
|
||||
locations: 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(),
|
||||
locations: locationsResolver(t),
|
||||
})
|
||||
// TODO: Add schema for other fields later.
|
||||
.passthrough()
|
||||
@@ -397,40 +400,40 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
formState: { isDirty: isFormDirty, dirtyFields },
|
||||
} = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url: string) => {
|
||||
const paths = url.split("/");
|
||||
|
||||
// Check if event is managed event type - skip if there is assigned users
|
||||
const assignedUsers = eventType.children;
|
||||
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||
if (eventType.assignAllTeamMembers) {
|
||||
return;
|
||||
} else if (isManagedEventType && assignedUsers.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts = eventType.hosts;
|
||||
if (
|
||||
!leaveWithoutAssigningHosts.current &&
|
||||
!!team &&
|
||||
(hosts.length === 0 || assignedUsers.length === 0) &&
|
||||
(url === "/event-types" || paths[1] !== "event-types")
|
||||
) {
|
||||
setIsOpenAssignmentWarnDialog(true);
|
||||
setPendingRoute(url);
|
||||
router.events.emit(
|
||||
"routeChangeError",
|
||||
new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
||||
);
|
||||
throw "Aborted";
|
||||
}
|
||||
};
|
||||
router.events.on("routeChangeStart", handleRouteChange);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteChange);
|
||||
};
|
||||
}, [router]);
|
||||
// useEffect(() => {
|
||||
// const handleRouteChange = (url: string) => {
|
||||
// const paths = url.split("/");
|
||||
//
|
||||
// // Check if event is managed event type - skip if there is assigned users
|
||||
// const assignedUsers = eventType.children;
|
||||
// const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||
// if (eventType.assignAllTeamMembers) {
|
||||
// return;
|
||||
// } else if (isManagedEventType && assignedUsers.length > 0) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const hosts = eventType.hosts;
|
||||
// if (
|
||||
// !leaveWithoutAssigningHosts.current &&
|
||||
// !!team &&
|
||||
// (hosts.length === 0 || assignedUsers.length === 0) &&
|
||||
// (url === "/event-types" || paths[1] !== "event-types")
|
||||
// ) {
|
||||
// setIsOpenAssignmentWarnDialog(true);
|
||||
// setPendingRoute(url);
|
||||
// router.events.emit(
|
||||
// "routeChangeError",
|
||||
// new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
||||
// );
|
||||
// throw "Aborted";
|
||||
// }
|
||||
// };
|
||||
// router.events.on("routeChangeStart", handleRouteChange);
|
||||
// return () => {
|
||||
// router.events.off("routeChangeStart", handleRouteChange);
|
||||
// };
|
||||
// }, [router]);
|
||||
|
||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||
const availability = formMethods.watch("availability");
|
||||
@@ -463,11 +466,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
|
||||
recurring: <EventRecurringTab eventType={eventType} />,
|
||||
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
|
||||
workflows: (
|
||||
<EventWorkflowsTab
|
||||
eventType={eventType}
|
||||
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
|
||||
/>
|
||||
workflows: props.allActiveWorkflows ? (
|
||||
<EventWorkflowsTab eventType={eventType} workflows={props.allActiveWorkflows} />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
webhooks: <EventWebhooksTab eventType={eventType} />,
|
||||
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
|
||||
@@ -704,7 +706,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
<EventTypeSingleLayout
|
||||
enabledAppsNumber={numberOfActiveApps}
|
||||
installedAppsNumber={eventTypeApps?.items.length || 0}
|
||||
enabledWorkflowsNumber={eventType.workflows.length}
|
||||
enabledWorkflowsNumber={props.allActiveWorkflows ? props.allActiveWorkflows.length : 0}
|
||||
eventType={eventType}
|
||||
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
|
||||
team={team}
|
||||
@@ -835,13 +837,13 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<AssignmentWarningDialog
|
||||
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
|
||||
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
|
||||
pendingRoute={pendingRoute}
|
||||
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
|
||||
id={eventType.id}
|
||||
/>
|
||||
{/*<AssignmentWarningDialog*/}
|
||||
{/* isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}*/}
|
||||
{/* setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}*/}
|
||||
{/* pendingRoute={pendingRoute}*/}
|
||||
{/* leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}*/}
|
||||
{/* id={eventType.id}*/}
|
||||
{/*/>*/}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -852,7 +854,25 @@ const EventTypePageWrapper: React.FC<PageProps> & {
|
||||
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
import { useTranscription } from "@daily-co/daily-react";
|
||||
import { useTranscription, useRecording } from "@daily-co/daily-react";
|
||||
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
|
||||
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
|
||||
|
||||
import { TRANSCRIPTION_STARTED_ICON, TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
|
||||
import {
|
||||
TRANSCRIPTION_STARTED_ICON,
|
||||
RECORDING_IN_PROGRESS_ICON,
|
||||
TRANSCRIPTION_STOPPED_ICON,
|
||||
RECORDING_DEFAULT_ICON,
|
||||
} from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const BUTTONS = {
|
||||
STOP_TRANSCRIPTION: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop transcription",
|
||||
iconPath: TRANSCRIPTION_STARTED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||
},
|
||||
START_TRANSCRIPTION: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
START_RECORDING: {
|
||||
label: "Record",
|
||||
tooltip: "Start recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
WAIT_FOR_RECORDING_TO_START: {
|
||||
label: "Starting..",
|
||||
tooltip: "Please wait while we start recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
STOP_RECORDING: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop recording",
|
||||
iconPath: RECORDING_IN_PROGRESS_ICON,
|
||||
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
|
||||
},
|
||||
};
|
||||
|
||||
export const CalAiTranscribe = () => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
@@ -15,6 +53,7 @@ export const CalAiTranscribe = () => {
|
||||
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const transcription = useTranscription();
|
||||
const recording = useRecording();
|
||||
|
||||
useDailyEvent(
|
||||
"app-message",
|
||||
@@ -26,36 +65,64 @@ export const CalAiTranscribe = () => {
|
||||
|
||||
useDailyEvent("transcription-started", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
transcription: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop transcription",
|
||||
iconPath: TRANSCRIPTION_STARTED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||
},
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("recording-started", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("transcription-stopped", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("custom-button-click", (ev) => {
|
||||
if (ev?.button_id !== "transcription") {
|
||||
return;
|
||||
}
|
||||
useDailyEvent("recording-stopped", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording?.isRecording) {
|
||||
await daily?.stopRecording();
|
||||
} else {
|
||||
daily?.updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
|
||||
await daily?.startRecording({
|
||||
// 480p
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTranscription = async () => {
|
||||
if (transcription?.isTranscribing) {
|
||||
daily?.stopTranscription();
|
||||
} else {
|
||||
daily?.startTranscription();
|
||||
}
|
||||
};
|
||||
|
||||
useDailyEvent("custom-button-click", async (ev) => {
|
||||
if (ev?.button_id === "recording") {
|
||||
toggleRecording();
|
||||
} else if (ev?.button_id === "transcription") {
|
||||
toggleTranscription();
|
||||
}
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import {
|
||||
generateGuestMeetingTokenFromOwnerMeetingToken,
|
||||
setEnableRecordingUIForOrganizer,
|
||||
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
@@ -39,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
references: {
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
type: true,
|
||||
meetingUrl: true,
|
||||
@@ -103,12 +108,31 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
// set meetingPassword to null for guests
|
||||
const oldVideoReference = getCalVideoReference(bookingObj.references);
|
||||
|
||||
// set meetingPassword for guests
|
||||
if (session?.user.id !== bookingObj.user?.id) {
|
||||
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
|
||||
oldVideoReference.meetingPassword
|
||||
);
|
||||
|
||||
bookingObj.references.forEach((bookRef) => {
|
||||
bookRef.meetingPassword = null;
|
||||
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);
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
|
||||
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
@@ -50,6 +50,12 @@ export default function JoinCall(props: PageProps) {
|
||||
...(typeof meetingPassword === "string" && { token: meetingPassword }),
|
||||
...(hasTeamPlan && {
|
||||
customTrayButtons: {
|
||||
recording: {
|
||||
label: "Record",
|
||||
tooltip: "Start or stop recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
|
||||
@@ -54,6 +54,20 @@ if (!process.env.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) => {
|
||||
try {
|
||||
const o = JSON.parse(jsonString);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.2.2",
|
||||
"version": "4.2.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -8,6 +8,7 @@
|
||||
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "next dev",
|
||||
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
|
||||
"dx": "yarn dev",
|
||||
"test-codegen": "yarn playwright codegen http://localhost:3000",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -66,7 +68,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
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
|
||||
// so we swap the emails and its verified status
|
||||
if (existingSecondaryUser?.userId === user.id) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
|
||||
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
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];
|
||||
if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` });
|
||||
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
|
||||
let redirectUrl = "/apps/installed";
|
||||
if (typeof handler === "undefined")
|
||||
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);
|
||||
} else {
|
||||
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 });
|
||||
}
|
||||
if (!res.writableEnded) return res.status(200);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { WebhookTriggerEvents } from "@prisma/client";
|
||||
import { createHmac } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
|
||||
import {
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
submitBatchProcessorTranscriptionJob,
|
||||
@@ -10,210 +9,48 @@ import {
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
|
||||
import { sendDailyVideoRecordingEmails } 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 { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
|
||||
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 commonSchema = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
event_ts: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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 computeSignature = (
|
||||
hmacSecret: string,
|
||||
reqBody: NextApiRequest["body"],
|
||||
webhookTimestampHeader: string | string[] | undefined
|
||||
) => {
|
||||
const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`;
|
||||
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
|
||||
const hmac = createHmac("sha256", base64DecodedSecret);
|
||||
const computed_signature = hmac.update(signature).digest("base64");
|
||||
return computed_signature;
|
||||
};
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
return booking;
|
||||
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
|
||||
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
|
||||
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
||||
const downloadLink = downloadLinkResponse.download_link;
|
||||
return downloadLink;
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_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" });
|
||||
}
|
||||
|
||||
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
|
||||
if (!hmacSecret) {
|
||||
return res.status(405).json({ message: "No Daily Webhook Secret" });
|
||||
}
|
||||
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
|
||||
|
||||
const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`;
|
||||
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
|
||||
const hmac = createHmac("sha256", base64DecodedSecret);
|
||||
const computed_signature = hmac.update(signature).digest("base64");
|
||||
if (!testMode) {
|
||||
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
|
||||
if (!hmacSecret) {
|
||||
return res.status(405).json({ message: "No Daily Webhook Secret" });
|
||||
}
|
||||
|
||||
if (req.headers["x-webhook-signature"] !== computed_signature) {
|
||||
return res.status(403).json({ message: "Signature does not match" });
|
||||
const computed_signature = computeSignature(hmacSecret, req.body, req.headers["x-webhook-timestamp"]);
|
||||
|
||||
if (req.headers["x-webhook-signature"] !== computed_signature) {
|
||||
return res.status(403).json({ message: "Signature does not match" });
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
@@ -261,19 +99,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
message: "Recording not finished",
|
||||
});
|
||||
}
|
||||
|
||||
const bookingReference = await getBookingReference(room_name);
|
||||
|
||||
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 booking = await getBooking(bookingReference.bookingId as number);
|
||||
|
||||
const evt = await getCalendarEvent(booking);
|
||||
|
||||
@@ -286,9 +114,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
});
|
||||
|
||||
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
|
||||
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
||||
const downloadLink = downloadLinkResponse.download_link;
|
||||
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
|
||||
|
||||
const teamId = await getTeamIdFromEventType({
|
||||
eventType: {
|
||||
@@ -297,7 +123,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
await triggerRecordingReadyWebhook({
|
||||
evt,
|
||||
downloadLink,
|
||||
booking: {
|
||||
@@ -317,6 +143,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
// send emails to all attendees only when user has team plan
|
||||
await sendDailyVideoRecordingEmails(evt, downloadLink);
|
||||
|
||||
return res.status(200).json({ message: "Success" });
|
||||
} else if (req.body.type === "meeting.ended") {
|
||||
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
|
||||
@@ -325,20 +152,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
message: "Invalid Payload",
|
||||
});
|
||||
}
|
||||
|
||||
const { room } = meetingEndedResponse.data.payload;
|
||||
|
||||
const bookingReference = await getBookingReference(room);
|
||||
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} does not exist or does not contain daily video as location`,
|
||||
});
|
||||
}
|
||||
const booking = await getBooking(bookingReference.bookingId as number);
|
||||
|
||||
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
|
||||
|
||||
@@ -349,10 +167,62 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await sendDailyVideoTranscriptEmails(evt, transcripts);
|
||||
|
||||
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) {
|
||||
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}
|
||||
variant={data.variant}
|
||||
type={data.type}
|
||||
dirName={data.dirName}
|
||||
logo={data.logo}
|
||||
categories={data.categories ?? [data.category]}
|
||||
author={data.publisher}
|
||||
|
||||
@@ -8,17 +8,22 @@ import { Toaster } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
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 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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl";
|
||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
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";
|
||||
|
||||
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
|
||||
Pick<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
|
||||
Pick<
|
||||
EventTypeModel,
|
||||
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
|
||||
> & {
|
||||
selected: boolean;
|
||||
locations: LocationFormValues["locations"];
|
||||
bookingFields?: LocationFormValues["bookingFields"];
|
||||
};
|
||||
|
||||
export type TEventTypesForm = {
|
||||
@@ -47,7 +57,6 @@ const STEPS = [
|
||||
AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
AppOnboardingSteps.CONFIGURE_STEP,
|
||||
] as const;
|
||||
const MAX_NUMBER_OF_STEPS = STEPS.length;
|
||||
|
||||
type StepType = (typeof STEPS)[number];
|
||||
|
||||
@@ -63,11 +72,21 @@ type StepObj = Record<
|
||||
type OnboardingPageProps = {
|
||||
appMetadata: AppMeta;
|
||||
step: StepType;
|
||||
teams: TeamsProp;
|
||||
teams?: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
eventTypes?: TEventType[];
|
||||
userName: string;
|
||||
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 = ({
|
||||
@@ -78,6 +97,9 @@ const OnboardingPage = ({
|
||||
eventTypes,
|
||||
userName,
|
||||
credentialId,
|
||||
showEventTypesStep,
|
||||
isConferencing,
|
||||
installableOnTeams,
|
||||
}: OnboardingPageProps) => {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
@@ -92,16 +114,15 @@ const OnboardingPage = ({
|
||||
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
|
||||
getTitle: () => `${t("select_event_types_header")}`,
|
||||
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
|
||||
stepNumber: 2,
|
||||
stepNumber: installableOnTeams ? 2 : 1,
|
||||
},
|
||||
[AppOnboardingSteps.CONFIGURE_STEP]: {
|
||||
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
|
||||
getDescription: () => `${t("configure_app_description")}`,
|
||||
stepNumber: 3,
|
||||
stepNumber: installableOnTeams ? 3 : 2,
|
||||
},
|
||||
} as const;
|
||||
const [configureStep, setConfigureStep] = useState(false);
|
||||
const [isSelectingAccount, setIsSelectingAccount] = useState(false);
|
||||
|
||||
const currentStep: AppOnboardingSteps = useMemo(() => {
|
||||
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
|
||||
@@ -111,6 +132,13 @@ const OnboardingPage = ({
|
||||
}, [step, configureStep]);
|
||||
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 formPortalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -120,9 +148,19 @@ const OnboardingPage = ({
|
||||
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(() => {
|
||||
eventTypes && formMethods.setValue("eventTypes", eventTypes);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventTypes]);
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
@@ -156,47 +194,22 @@ const OnboardingPage = ({
|
||||
});
|
||||
|
||||
const handleSelectAccount = async (teamId?: number) => {
|
||||
try {
|
||||
setIsSelectingAccount(true);
|
||||
if (appMetadata.isOAuth) {
|
||||
const state = JSON.stringify({
|
||||
appOnboardingRedirectUrl: getAppOnboardingRedirectUrl(appMetadata.slug, teamId),
|
||||
teamId,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`/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(
|
||||
mutation.mutate({
|
||||
type: appMetadata.type,
|
||||
variant: appMetadata.variant,
|
||||
slug: appMetadata.slug,
|
||||
...(teamId && { teamId }),
|
||||
// for oAuth apps
|
||||
...(showEventTypesStep && {
|
||||
returnTo:
|
||||
WEBAPP_URL +
|
||||
getAppOnboardingUrl({
|
||||
slug: appMetadata.slug,
|
||||
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSelectingAccount(false);
|
||||
router.push(`/apps`);
|
||||
}
|
||||
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetUpLater = () => {
|
||||
@@ -233,10 +246,21 @@ const OnboardingPage = ({
|
||||
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
|
||||
throw new Error(t("seats_and_no_show_fee_error"));
|
||||
}
|
||||
return updateMutation.mutateAsync({
|
||||
id: value.id,
|
||||
metadata: value.metadata,
|
||||
});
|
||||
let updateObject: TUpdateObject = { id: value.id };
|
||||
if (isConferencing) {
|
||||
updateObject = {
|
||||
...updateObject,
|
||||
locations: value.locations,
|
||||
bookingFields: value.bookingFields ? value.bookingFields : undefined,
|
||||
};
|
||||
} else {
|
||||
updateObject = {
|
||||
...updateObject,
|
||||
metadata: value.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
return updateMutation.mutateAsync(updateObject);
|
||||
});
|
||||
try {
|
||||
await Promise.all(mutationPromises);
|
||||
@@ -248,14 +272,15 @@ const OnboardingPage = ({
|
||||
<StepHeader
|
||||
title={stepObj.getTitle(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>
|
||||
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
|
||||
<AccountsStepCard
|
||||
teams={teams}
|
||||
personalAccount={personalAccount}
|
||||
onSelect={handleSelectAccount}
|
||||
loading={isSelectingAccount}
|
||||
loading={mutation.isPending}
|
||||
installableOnTeams={installableOnTeams}
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
|
||||
@@ -278,6 +303,7 @@ const OnboardingPage = ({
|
||||
setConfigureStep={setConfigureStep}
|
||||
eventTypes={eventTypes}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
isConferencing={isConferencing}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
@@ -385,19 +411,31 @@ const getEventTypes = async (userId: number, teamId?: number) => {
|
||||
users: { select: { username: true } },
|
||||
seatsPerTimeSlot: 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) => {
|
||||
return eventTypeB.position - eventTypeA.position;
|
||||
});
|
||||
|
||||
if (eventTypes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return eventTypes.map((item) => ({
|
||||
...item,
|
||||
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
|
||||
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 app = await getAppBySlug(parsedAppSlug);
|
||||
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 (!hasEventTypes) {
|
||||
throw new Error(ERROR_MESSAGES.appNotExtendsEventType);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
@@ -460,7 +499,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
return {
|
||||
redirect: {
|
||||
@@ -498,6 +561,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
app,
|
||||
appMetadata,
|
||||
showEventTypesStep,
|
||||
step: parsedStepParam,
|
||||
teams: teamsWithIsAppInstalled,
|
||||
personalAccount,
|
||||
@@ -505,9 +569,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
teamId: parsedTeamIdParam ?? null,
|
||||
userName: user.username,
|
||||
credentialId,
|
||||
isConferencing,
|
||||
// conferencing apps dont support team install
|
||||
installableOnTeams: !isConferencing,
|
||||
} as OnboardingPageProps,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log("eerrerrerrerrerrerrerrerrrr: ", err);
|
||||
if (err instanceof z.ZodError) {
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
}
|
||||
|
||||
@@ -642,6 +642,7 @@ const ProfileForm = ({
|
||||
{showRemoveAvatarButton && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="hover:bg-red-600"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
}}>
|
||||
|
||||
@@ -123,15 +123,8 @@ function UsernameField({
|
||||
});
|
||||
}
|
||||
checkUsername();
|
||||
}, [
|
||||
debouncedUsername,
|
||||
setPremium,
|
||||
disabled,
|
||||
orgSlug,
|
||||
setUsernameTaken,
|
||||
formState.isSubmitting,
|
||||
formState.isSubmitSuccessful,
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedUsername, disabled, orgSlug, formState.isSubmitting, formState.isSubmitSuccessful]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -5,39 +5,41 @@ const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"];
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Check analytics Apps ", () => {
|
||||
test("Check analytics Apps by skipping the configure step", async ({ appsPage, page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await page.goto("/apps/");
|
||||
await appsPage.goToAppsCategory("analytics");
|
||||
for (const app of ALL_APPS) {
|
||||
await appsPage.installAppSkipConfigure(app);
|
||||
await appsPage.goBackToAppsPage();
|
||||
}
|
||||
await page.goto("/event-types");
|
||||
await appsPage.goToEventType("30 min");
|
||||
await appsPage.goToAppsTab();
|
||||
await appsPage.verifyAppsInfo(0);
|
||||
for (const app of ALL_APPS) {
|
||||
await appsPage.activeApp(app);
|
||||
}
|
||||
await appsPage.verifyAppsInfo(6);
|
||||
test.describe("check analytics Apps", () => {
|
||||
test.describe("check analytics apps by skipping the configure step", () => {
|
||||
ALL_APPS.forEach((app) => {
|
||||
test(`check analytics app: ${app} by skipping the configure step`, async ({
|
||||
appsPage,
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await page.goto("apps/categories/analytics");
|
||||
await appsPage.installAnalyticsAppSkipConfigure(app);
|
||||
|
||||
await page.goto("/event-types");
|
||||
await appsPage.goToEventType("30 min");
|
||||
await appsPage.goToAppsTab();
|
||||
await appsPage.verifyAppsInfo(0);
|
||||
await appsPage.activeApp(app);
|
||||
await appsPage.verifyAppsInfo(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Check analytics Apps using the new flow", async ({ appsPage, page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
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 appsPage.installApp(app, eventTypesIds);
|
||||
}
|
||||
|
||||
for (const id of eventTypesIds) {
|
||||
await appsPage.verifyAppsInfoNew(ALL_APPS, id);
|
||||
}
|
||||
test.describe("check analytics apps using the new flow", () => {
|
||||
ALL_APPS.forEach((app) => {
|
||||
test(`check analytics app: ${app}`, async ({ appsPage, page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const eventTypes = await user.getUserEventsAsOwner();
|
||||
const eventTypesIds = eventTypes.map((item) => item.id);
|
||||
await page.goto("/apps/categories/analytics");
|
||||
await appsPage.installAnalyticsApp(app, eventTypesIds);
|
||||
for (const id of eventTypesIds) {
|
||||
await appsPage.verifyAppsInfoNew(app, 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user