Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b577fb7a | ||
|
|
7f97d27c20 | ||
|
|
9ff10877e7 | ||
|
|
eb0768917c | ||
|
|
ec65cd6333 | ||
|
|
563141b295 | ||
|
|
7b24972900 | ||
|
|
6d2ec71df3 | ||
|
|
492304fe1d | ||
|
|
c7c5cdf133 | ||
|
|
0dd3b25624 | ||
|
|
a633e35c66 | ||
|
|
c618e69d8e | ||
|
|
844dd7e3af | ||
|
|
b6c512e5d7 | ||
|
|
6e0035689a | ||
|
|
58540af9ce | ||
|
|
21f30fc47a | ||
|
|
92845d3adc | ||
|
|
8ee27d3997 | ||
|
|
1263519973 | ||
|
|
caf7943c91 | ||
|
|
fed3987028 | ||
|
|
ad61d48706 | ||
|
|
aa20b9248a | ||
|
|
fa40f1ead5 | ||
|
|
86676eb8af | ||
|
|
3f9d1c1abf | ||
|
|
5f8d090d3c | ||
|
|
4a2a2d165b | ||
|
|
263c0ac7ef | ||
|
|
f1b4d7d9e0 | ||
|
|
7d8cb7d6bb | ||
|
|
5ff7e65d64 | ||
|
|
5bbc025494 | ||
|
|
59bffa075f | ||
|
|
c4104af8a2 | ||
|
|
dca2922ef8 | ||
|
|
8b5ef68652 | ||
|
|
5b2688dda0 | ||
|
|
884ccc9b48 | ||
|
|
7d06d9f865 | ||
|
|
785ab8b74a | ||
|
|
557498b80d | ||
|
|
c3a532cff0 | ||
|
|
ab5d4f1d03 | ||
|
|
ae79154b83 | ||
|
|
e33c676ec5 | ||
|
|
8cd9285cc6 | ||
|
|
4f76d4468e | ||
|
|
92bf1ee3c7 | ||
|
|
e9aec23fbe | ||
|
|
a6a60969df | ||
|
|
e9d828639a | ||
|
|
726c938b6d | ||
|
|
b43aaaccc2 | ||
|
|
0b5f828307 | ||
|
|
3807e339ce | ||
|
|
ececef96e5 | ||
|
|
50338dcd61 | ||
|
|
285d1ec4c9 | ||
|
|
c458b297ae | ||
|
|
a52f7efe88 | ||
|
|
3e10e2db36 | ||
|
|
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
|
||||
|
||||
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
||||
|
||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||
|
||||
<!-- RORADMAP -->
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||
|
||||
> [!NOTE]
|
||||
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||
|
||||
| | AGPLv3 | EE |
|
||||
|---|---|---|
|
||||
| Self-host for commercial purposes | ✅ | ✅ |
|
||||
| Clone privately | ✅ | ✅ |
|
||||
| Fork publicly | ✅ | ✅ |
|
||||
| Requires CLA | ✅ | ✅ |
|
||||
| Official Support| ❌ | ✅ |
|
||||
| Derivative work privately | ❌ | ✅ |
|
||||
| SSO | ❌ | ✅ |
|
||||
| Admin Panel | ❌ | ✅ |
|
||||
| Impersonation | ❌ | ✅ |
|
||||
| Managed Event Types | ❌ | ✅ |
|
||||
| Organizations | ❌ | ✅ |
|
||||
| Payments | ❌ | ✅ |
|
||||
| Platform | ❌ | ✅ |
|
||||
| Teams | ❌ | ✅ |
|
||||
| Users | ❌ | ✅ |
|
||||
| Video | ❌ | ✅ |
|
||||
| Workflows | ❌ | ✅ |
|
||||
|
||||
> [!TIP]
|
||||
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
|
||||
|
||||
## Repo Activity
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
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 { 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";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
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,
|
||||
OrgTeamOutputResponseDto,
|
||||
OrgTeamsOutputResponseDto,
|
||||
} 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 { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { 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()
|
||||
@ApiOperation({ summary: "Get all the teams of an organization." })
|
||||
@UseGuards()
|
||||
@Roles("ORG_ADMIN")
|
||||
async getAllTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<OrgTeamsOutputResponseDto> {
|
||||
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")
|
||||
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
||||
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@Delete("/:teamId")
|
||||
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
||||
async deleteTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
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")
|
||||
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
||||
async updateTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards()
|
||||
@Roles("ORG_ADMIN")
|
||||
@ApiOperation({ summary: "Create a team for an organization." })
|
||||
async createTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
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,141 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class OrgTeamOutputDto {
|
||||
@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";
|
||||
}
|
||||
|
||||
export class OrgTeamsOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgTeamOutputDto)
|
||||
data!: OrgTeamOutputDto[];
|
||||
}
|
||||
|
||||
export class OrgTeamOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgTeamOutputDto)
|
||||
data!: OrgTeamOutputDto;
|
||||
}
|
||||
+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) {
|
||||
|
||||
+1019
-263
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,6 +1,9 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/event-types/getServerSideProps";
|
||||
|
||||
import EventTypes from "~/event-types/views/event-types-listing-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -9,4 +12,6 @@ export const generateMetadata = async () =>
|
||||
(t) => t("event_types_page_subtitle")
|
||||
);
|
||||
|
||||
export default WithLayout({ getLayout: null, Page: EventTypes })<"P">;
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData, Page: EventTypes })<"P">;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import NotFoundPage from "@pages/404";
|
||||
import { 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.3.0",
|
||||
"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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user