Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72f7f8a42 | ||
|
|
3a4ebadbf3 | ||
|
|
765d601ff9 | ||
|
|
3a22fdb428 | ||
|
|
93580ac4bc | ||
|
|
b15ad6f233 | ||
|
|
56e59ab3e1 | ||
|
|
ee796933ee | ||
|
|
d2f76edfd6 | ||
|
|
822f0204a6 | ||
|
|
9770aff153 | ||
|
|
723d0ddec2 | ||
|
|
60b61eb625 | ||
|
|
5d2d99006b | ||
|
|
5d01eb2f38 | ||
|
|
ac3fb0a1cd | ||
|
|
1730ef7d5d | ||
|
|
9e371ded90 | ||
|
|
15098bdd1e | ||
|
|
625a7ec180 | ||
|
|
5602f7bd95 | ||
|
|
3e2caa9e1e | ||
|
|
b905de435d | ||
|
|
23bb06cc36 | ||
|
|
dd24100035 | ||
|
|
7434d91d48 | ||
|
|
3b1de34451 | ||
|
|
4cf89d273e | ||
|
|
e58ce4af71 | ||
|
|
15122dd5e9 | ||
|
|
793c0c60e3 | ||
|
|
344454e574 | ||
|
|
86342d103a | ||
|
|
d4f3726875 | ||
|
|
817a3decb7 | ||
|
|
5f082e11f8 | ||
|
|
5fb5081faa | ||
|
|
17f328fc5e | ||
|
|
d19034c2d5 | ||
|
|
4cdcc85620 | ||
|
|
3f51ae626c | ||
|
|
8499aebe88 | ||
|
|
07f613050e | ||
|
|
2c8c8368a0 | ||
|
|
74d3a84e27 | ||
|
|
33c1d49c52 | ||
|
|
889ff177c1 | ||
|
|
d8a6c375d0 | ||
|
|
655127d155 | ||
|
|
17c4a10e33 | ||
|
|
30f9f19a9f | ||
|
|
be7d45afe2 | ||
|
|
d2bec46d89 | ||
|
|
b90500778e | ||
|
|
bddc235c3c | ||
|
|
360b729d08 | ||
|
|
5d325495a9 | ||
|
|
dead2b843a | ||
|
|
1174cb0d8e | ||
|
|
58e6f18594 | ||
|
|
ffe6a46e66 | ||
|
|
37c02572de | ||
|
|
b82b0a33a4 | ||
|
|
b1bbbcee90 | ||
|
|
1a381695e5 | ||
|
|
8831ff13c7 | ||
|
|
64845c670d | ||
|
|
ba06c6c26a | ||
|
|
49df99cf92 | ||
|
|
0cb2e60d9b | ||
|
|
b3485b07bf | ||
|
|
01933e5c15 | ||
|
|
5f5cf92456 | ||
|
|
29ccc58806 | ||
|
|
26bd02bd94 | ||
|
|
04bf23ed69 | ||
|
|
a7b5fd593e | ||
|
|
6dd4933337 | ||
|
|
4217efc5a8 | ||
|
|
aca2b29aa4 |
@@ -359,3 +359,6 @@ UNKEY_ROOT_KEY=
|
||||
# Used for Cal.ai Enterprise Voice AI Agents
|
||||
# https://retellai.com
|
||||
RETELL_AI_KEY=
|
||||
|
||||
# Used to disallow emails as being added as guests on bookings
|
||||
BLACKLISTED_GUEST_EMAILS=
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules
|
||||
apps/api/v2/dist
|
||||
packages/platform/**/dist/*
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
name: All checks
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type check
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Linters
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
unit-test:
|
||||
name: Tests
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
build-api-v1:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/api-v1-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-api-v2:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
integration-test:
|
||||
name: Tests
|
||||
needs: [lint, build, build-api-v1, build-api-v2]
|
||||
uses: ./.github/workflows/integration-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: Tests
|
||||
needs: [lint, build, build-api-v1, build-api-v2]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: Tests
|
||||
needs: [lint, build, build-api-v1, build-api-v2]
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: Tests
|
||||
needs: [lint, build, build-api-v1, build-api-v2]
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: Tests
|
||||
needs: [lint, build, build-api-v1, build-api-v2]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
name: Analyze Build
|
||||
needs: [build]
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, type-check, unit-test, integration-test, 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
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
@@ -41,7 +41,7 @@ env:
|
||||
jobs:
|
||||
e2e-app-store:
|
||||
timeout-minutes: 20
|
||||
name: E2E App Store (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||
name: E2E App Store
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
services:
|
||||
postgres:
|
||||
@@ -70,9 +70,6 @@ jobs:
|
||||
- 1025:1025
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
## There aren't many tests for AppStore. So, just start with 2 shards. Increase if needed.
|
||||
shard: [1, 2]
|
||||
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
@@ -93,10 +90,10 @@ jobs:
|
||||
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
run: yarn e2e:app-store
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
name: app-store-results
|
||||
path: test-results
|
||||
|
||||
@@ -41,7 +41,7 @@ env:
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
name: E2E Embed React (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||
name: E2E Embed React
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
services:
|
||||
postgres:
|
||||
@@ -62,9 +62,6 @@ jobs:
|
||||
- 5432:5432
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
## There aren't many tests for embed-react. So, just start with 2 shards. Increase if needed.
|
||||
shard: [1, 2]
|
||||
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
@@ -79,11 +76,11 @@ jobs:
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: |
|
||||
yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
yarn e2e:embed-react
|
||||
yarn workspace @calcom/embed-react packaged:tests
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
name: embed-react-results
|
||||
path: test-results
|
||||
|
||||
@@ -41,7 +41,7 @@ env:
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
name: E2E Embed Core (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||
name: E2E Embed Core
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
services:
|
||||
postgres:
|
||||
@@ -70,9 +70,6 @@ jobs:
|
||||
- 1025:1025
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
## There aren't many tests for embed-core. So, just start with 2 shards. Increase if needed.
|
||||
shard: [1, 2]
|
||||
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
@@ -86,10 +83,10 @@ jobs:
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
run: yarn e2e:embed
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
name: embed-core-results
|
||||
path: test-results
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
name: E2E (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -75,15 +75,16 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
- name: Run Tests
|
||||
run: yarn test -- --integrationTestsOnly
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
||||
# TODO: Generate test results so we can upload them
|
||||
# - name: Upload Test Results
|
||||
# if: ${{ always() }}
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: test-results
|
||||
# path: test-results
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -35,13 +34,6 @@ jobs:
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
secrets: inherit
|
||||
|
||||
unit-test:
|
||||
name: Tests
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Linters
|
||||
needs: [changes]
|
||||
@@ -49,71 +41,22 @@ jobs:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build-api-v1:
|
||||
name: Production builds
|
||||
unit-test:
|
||||
name: Tests
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/api-v1-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-api-v2:
|
||||
name: Production builds
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
integration-test:
|
||||
name: Tests
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/integration-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: Tests
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: Tests
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: Tests
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: Tests
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
name: Analyze Build
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [changes, lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
needs: [changes, lint, type-check, unit-test, integration-test]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ To develop locally:
|
||||
|
||||
- Duplicate `.env.example` to `.env`.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
6. Setup Node
|
||||
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||
|
||||
@@ -55,7 +55,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
})
|
||||
.optional(),
|
||||
.nullish(),
|
||||
payment: z
|
||||
.array(
|
||||
_PaymentModel.pick({
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient";
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /bookings/{id}/recordings:
|
||||
* get:
|
||||
* summary: Find all Cal video recordings of that booking
|
||||
* operationId: getRecordingsByBookingId
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* schema:
|
||||
* type: integer
|
||||
* required: true
|
||||
* description: ID of the booking for which recordings need to be fetched. Recording download link is only valid for 12 hours and you would have to fetch the recordings again to get new download link
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* tags:
|
||||
* - bookings
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: "#/components/schemas/ArrayOfRecordings"
|
||||
* examples:
|
||||
* recordings:
|
||||
* value:
|
||||
* - id: "ad90a2e7-154f-49ff-a815-5da1db7bf899"
|
||||
* room_name: "0n22w24AQ5ZFOtEKX2gX"
|
||||
* start_ts: 1716215386
|
||||
* status: "finished"
|
||||
* max_participants: 1
|
||||
* duration: 11
|
||||
* share_token: "x94YK-69Gnh7"
|
||||
* download_link: "https://daily-meeting-recordings..."
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
* description: Booking was not found
|
||||
*/
|
||||
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: { references: true },
|
||||
});
|
||||
|
||||
if (!booking)
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `No Booking found with booking id ${id}`,
|
||||
});
|
||||
|
||||
const roomName =
|
||||
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
|
||||
undefined;
|
||||
|
||||
if (!roomName)
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `No Cal Video reference found with booking id ${booking.id}`,
|
||||
});
|
||||
|
||||
const recordings = await getRecordingsOfCalVideoByRoomName(roomName);
|
||||
|
||||
if (!recordings || !("data" in recordings)) return [];
|
||||
|
||||
const recordingWithDownloadLink = recordings.data.map((recording: RecordingItemSchema) => {
|
||||
return getDownloadLinkOfCalVideoByRecordingId(recording.id)
|
||||
.then((res) => ({
|
||||
...recording,
|
||||
download_link: res?.download_link,
|
||||
}))
|
||||
.catch((err) => ({ ...recording, download_link: null, error: err.message }));
|
||||
});
|
||||
const res = await Promise.all(recordingWithDownloadLink);
|
||||
return res;
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
|
||||
import authMiddleware from "../_auth-middleware";
|
||||
|
||||
export default withMiddleware()(
|
||||
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await authMiddleware(req);
|
||||
return defaultHandler({
|
||||
GET: import("./_get"),
|
||||
})(req, res);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /bookings/{id}/transcripts:
|
||||
* get:
|
||||
* summary: Find all Cal video transcripts of that booking
|
||||
* operationId: getTranscriptsByBookingId
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* schema:
|
||||
* type: integer
|
||||
* required: true
|
||||
* description: ID of the booking for which recordings need to be fetched.
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* tags:
|
||||
* - bookings
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
* description: Booking was not found
|
||||
*/
|
||||
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: { references: true },
|
||||
});
|
||||
|
||||
if (!booking)
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `No Booking found with booking id ${id}`,
|
||||
});
|
||||
|
||||
const roomName =
|
||||
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
|
||||
undefined;
|
||||
|
||||
if (!roomName)
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `No Cal Video reference found with booking id ${booking.id}`,
|
||||
});
|
||||
|
||||
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName);
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
|
||||
import authMiddleware from "../_auth-middleware";
|
||||
|
||||
export default withMiddleware()(
|
||||
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await authMiddleware(req);
|
||||
return defaultHandler({
|
||||
GET: import("./_get"),
|
||||
})(req, res);
|
||||
})
|
||||
);
|
||||
@@ -180,8 +180,10 @@ export async function handler(req: NextApiRequest) {
|
||||
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
|
||||
|
||||
const args: Prisma.BookingFindManyArgs = {};
|
||||
args.take = take;
|
||||
args.skip = skip;
|
||||
if (req.query.take && req.query.page) {
|
||||
args.take = take;
|
||||
args.skip = skip;
|
||||
}
|
||||
args.include = {
|
||||
attendees: true,
|
||||
user: true,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
@@ -218,7 +220,16 @@ async function handler(req: NextApiRequest) {
|
||||
req.userId = requestedUserId || userId;
|
||||
}
|
||||
|
||||
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
||||
try {
|
||||
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
||||
} catch (error: unknown) {
|
||||
const knownError = error as Error;
|
||||
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
||||
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -27,6 +27,37 @@ const swaggerHandler = withSwagger({
|
||||
$ref: "#/components/schemas/Booking",
|
||||
},
|
||||
},
|
||||
ArrayOfRecordings: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/Recording",
|
||||
},
|
||||
},
|
||||
Recording: {
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
},
|
||||
room_name: {
|
||||
type: "string",
|
||||
},
|
||||
start_ts: {
|
||||
type: "number",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
},
|
||||
max_participants: {
|
||||
type: "number",
|
||||
},
|
||||
duration: {
|
||||
type: "number",
|
||||
},
|
||||
download_link: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
Booking: {
|
||||
properties: {
|
||||
id: {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/core/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware";
|
||||
import handler from "../../../../../pages/api/bookings/[id]/recordings/_get";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
const adminUserId = 1;
|
||||
const memberUserId = 10;
|
||||
|
||||
vi.mock("@calcom/core/videoClient", () => {
|
||||
return {
|
||||
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
|
||||
return {
|
||||
getAccessibleUsers: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const mockGetRecordingsAndDownloadLink = () => {
|
||||
const download_link = "https://URL";
|
||||
const recordingItem = {
|
||||
id: "TEST_ID",
|
||||
room_name: "0n22w24AQ5ZFOtEKX2gX",
|
||||
start_ts: 1716215386,
|
||||
status: "finished",
|
||||
max_participants: 1,
|
||||
duration: 11,
|
||||
share_token: "TEST_TOKEN",
|
||||
};
|
||||
|
||||
vi.mocked(getRecordingsOfCalVideoByRoomName).mockResolvedValue({ data: [recordingItem], total_count: 1 });
|
||||
|
||||
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
|
||||
download_link,
|
||||
});
|
||||
|
||||
return [{ ...recordingItem, download_link }];
|
||||
};
|
||||
|
||||
describe("GET /api/bookings/[id]/recordings", () => {
|
||||
test("Returns recordings if user is system-wide admin", async () => {
|
||||
const userId = 2;
|
||||
|
||||
const bookingId = 1111;
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(
|
||||
buildBooking({
|
||||
id: bookingId,
|
||||
userId,
|
||||
references: [
|
||||
{
|
||||
id: 1,
|
||||
type: "daily_video",
|
||||
uid: "17OHkCH53pBa03FhxMbw",
|
||||
meetingId: "17OHkCH53pBa03FhxMbw",
|
||||
meetingPassword: "password",
|
||||
meetingUrl: "https://URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const mockedRecordings = mockGetRecordingsAndDownloadLink();
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = adminUserId;
|
||||
|
||||
await authMiddleware(req);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res._getData())).toEqual(mockedRecordings);
|
||||
});
|
||||
|
||||
test("Allows GET recordings when user is org-wide admin", async () => {
|
||||
const bookingId = 3333;
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(
|
||||
buildBooking({
|
||||
id: bookingId,
|
||||
userId: memberUserId,
|
||||
references: [
|
||||
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = adminUserId;
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
const mockedRecordings = mockGetRecordingsAndDownloadLink();
|
||||
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
|
||||
|
||||
await authMiddleware(req);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware";
|
||||
import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/core/videoClient", () => {
|
||||
return {
|
||||
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
|
||||
return {
|
||||
getAccessibleUsers: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const mockGetTranscripts = () => {
|
||||
const downloadLinks = ["https://URL1", "https://URL2"];
|
||||
|
||||
vi.mocked(getAllTranscriptsAccessLinkFromRoomName).mockResolvedValue(downloadLinks);
|
||||
|
||||
return downloadLinks;
|
||||
};
|
||||
|
||||
describe("GET /api/bookings/[id]/transcripts", () => {
|
||||
test("Returns transcripts if user is system-wide admin", async () => {
|
||||
const adminUserId = 1;
|
||||
const userId = 2;
|
||||
|
||||
const bookingId = 1111;
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(
|
||||
buildBooking({
|
||||
id: bookingId,
|
||||
userId,
|
||||
references: [
|
||||
{
|
||||
id: 1,
|
||||
type: "daily_video",
|
||||
uid: "17OHkCH53pBa03FhxMbw",
|
||||
meetingId: "17OHkCH53pBa03FhxMbw",
|
||||
meetingPassword: "password",
|
||||
meetingUrl: "https://URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const mockedTranscripts = mockGetTranscripts();
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = adminUserId;
|
||||
|
||||
await authMiddleware(req);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
|
||||
});
|
||||
|
||||
test("Allows GET transcripts when user is org-wide admin", async () => {
|
||||
const adminUserId = 1;
|
||||
const memberUserId = 10;
|
||||
const bookingId = 3333;
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(
|
||||
buildBooking({
|
||||
id: bookingId,
|
||||
userId: memberUserId,
|
||||
references: [
|
||||
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = adminUserId;
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
mockGetTranscripts();
|
||||
|
||||
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
|
||||
|
||||
await authMiddleware(req);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,8 @@
|
||||
"build": "yarn dev:build && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch & yarn workspace @calcom/platform-libraries build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||
@@ -25,11 +25,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-libraries": "*",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"next-auth": "^4.22.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"querystring": "^0.2.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^15.3.0",
|
||||
|
||||
@@ -50,8 +50,6 @@ import { AppController } from "./app.controller";
|
||||
EndpointsModule,
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
//register prefix for all routes in EndpointsModule
|
||||
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
+28
-5
@@ -9,18 +9,33 @@ import { HttpAdapterHost } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import { Request } from "express";
|
||||
import helmet from "helmet";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import {
|
||||
API_VERSIONS,
|
||||
VERSION_2024_04_15,
|
||||
API_VERSIONS_ENUM,
|
||||
CAL_API_VERSION_HEADER,
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
|
||||
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
prefix: "v",
|
||||
defaultVersion: "1",
|
||||
type: VersioningType.CUSTOM,
|
||||
extractor: (request: unknown) => {
|
||||
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
|
||||
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
|
||||
return headerVersion;
|
||||
}
|
||||
return VERSION_2024_04_15;
|
||||
},
|
||||
defaultVersion: VERSION_2024_04_15,
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
@@ -28,7 +43,15 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
|
||||
allowedHeaders: [
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
CAL_API_VERSION_HEADER,
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Origin",
|
||||
],
|
||||
maxAge: 86_400,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.2";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.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";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -37,14 +38,14 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
handleNewBooking,
|
||||
BookingResponse,
|
||||
HttpError,
|
||||
handleNewRecurringBooking,
|
||||
handleInstantMeeting,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -72,8 +73,8 @@ const DEFAULT_PLATFORM_PARAMS = {
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/bookings",
|
||||
version: "2",
|
||||
path: "/v2/bookings",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
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 { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
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 {
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
GOOGLE_CALENDAR_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
|
||||
class CalendarsServiceMock {
|
||||
async getCalendars() {
|
||||
return {
|
||||
connectedCalendars: [
|
||||
{
|
||||
integration: {
|
||||
type: "google_calendar",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Platform Calendars Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
|
||||
let user: User;
|
||||
let office365Credentials: Credential;
|
||||
let googleCalendarCredentials: Credential;
|
||||
let accessTokenSecret: string;
|
||||
let refreshTokenSecret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.overrideProvider(CalendarsService)
|
||||
.useClass(CalendarsServiceMock)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
|
||||
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
|
||||
accessTokenSecret = tokens.accessToken;
|
||||
refreshTokenSecret = tokens.refreshToken;
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: [CLIENT_REDIRECT_URI],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(oAuthClient).toBeDefined();
|
||||
expect(accessTokenSecret).toBeDefined();
|
||||
expect(refreshTokenSecret).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer invalid_access_token`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
const data = response.body.data;
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
const data = response.body.data;
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/random-calendar/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
|
||||
await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
|
||||
office365Credentials = await credentialsRepositoryFixture.create(
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
{},
|
||||
user.id,
|
||||
OFFICE_365_CALENDAR_ID
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
|
||||
googleCalendarCredentials = await credentialsRepositoryFixture.create(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
{},
|
||||
user.id,
|
||||
GOOGLE_CALENDAR_ID
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
||||
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Request } from "express";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
export interface CalendarApp {
|
||||
save(state: string, code: string, origin: string): Promise<{ url: string }>;
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface OAuthCalendarApp extends CalendarApp {
|
||||
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule],
|
||||
providers: [CredentialsRepository, CalendarsService],
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
OutlookService,
|
||||
GoogleCalendarService,
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
],
|
||||
controllers: [CalendarsController],
|
||||
exports: [CalendarsService],
|
||||
})
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
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";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Req,
|
||||
Param,
|
||||
Headers,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
import { APPS_READ } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/calendars",
|
||||
version: "2",
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@DocsTags("Calendars")
|
||||
export class CalendarsController {
|
||||
constructor(private readonly calendarsService: CalendarsService) {}
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService
|
||||
) {}
|
||||
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@Get("/busy-times")
|
||||
async getBusyTimes(
|
||||
@Query() queryParams: CalendarBusyTimesInput,
|
||||
@@ -55,4 +79,70 @@ export class CalendarsController {
|
||||
data: calendars,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.connect(authorization, req);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.connect(authorization, req);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/save")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Redirect(undefined, 301)
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin } = z
|
||||
.object({ accessToken: z.string(), origin: z.string() })
|
||||
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
|
||||
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(code, accessToken, origin);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@Permissions([APPS_READ])
|
||||
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.check(userId);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.check(userId);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import {
|
||||
CredentialsRepository,
|
||||
CredentialsWithUserEmail,
|
||||
@@ -11,21 +12,27 @@ import {
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries";
|
||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries";
|
||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsService {
|
||||
private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
@@ -110,4 +117,24 @@ export class CalendarsService {
|
||||
});
|
||||
return composedSelectedCalendars;
|
||||
}
|
||||
|
||||
async getAppKeys(appName: string) {
|
||||
const app = await this.appsRepository.getAppBySlug(appName);
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
const CALENDAR_SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
private logger = new Logger("GcalService");
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "");
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}`,
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
async getOAuthClient(redirectUri: string) {
|
||||
this.logger.log("Getting Google Calendar OAuth Client");
|
||||
const app = await this.appsRepository.getAppBySlug("google-calendar");
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
||||
return oAuth2Client;
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
}
|
||||
|
||||
if (gcalCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid google oauth credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const googleCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
if (!googleCalendar) {
|
||||
throw new UnauthorizedException("Google Calendar not connected.");
|
||||
}
|
||||
if (googleCalendar.error?.message) {
|
||||
throw new UnauthorizedException(googleCalendar.error?.message);
|
||||
}
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) {
|
||||
// User chose not to authorize your app or didn't authorize your app
|
||||
// redirect directly without oauth code
|
||||
if (!code) {
|
||||
return { url: origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
|
||||
if (primaryCal?.id) {
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return { url: origin };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { OFFICE_365_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class OutlookService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/calendars/office365/save`;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "");
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
const params = {
|
||||
response_type: "code",
|
||||
scope: scopes.join(" "),
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}`,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!office365CalendarCredentials) {
|
||||
throw new BadRequestException("Credentials for office_365_calendar not found.");
|
||||
}
|
||||
|
||||
if (office365CalendarCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid office 365 calendar credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const office365Calendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
if (!office365Calendar) {
|
||||
throw new UnauthorizedException("Office 365 calendar not connected.");
|
||||
}
|
||||
if (office365Calendar.error?.message) {
|
||||
throw new UnauthorizedException(office365Calendar.error?.message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
async getOAuthCredentials(code: string) {
|
||||
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
|
||||
const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
Object.keys(payload)
|
||||
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
|
||||
.join("&");
|
||||
|
||||
const body = toUrlEncoded({
|
||||
client_id,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
scope: scopes.join(" "),
|
||||
redirect_uri: this.redirectUri,
|
||||
client_secret,
|
||||
});
|
||||
|
||||
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
async getDefaultCalendar(accessToken: string): Promise<OfficeCalendar> {
|
||||
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const responseBody = await response.json();
|
||||
|
||||
return responseBody as OfficeCalendar;
|
||||
}
|
||||
|
||||
async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) {
|
||||
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||
if (!code) {
|
||||
return { url: origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
|
||||
|
||||
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
|
||||
|
||||
if (defaultCalendar?.id) {
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
office365OAuthCredentials,
|
||||
ownerId
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
defaultCalendar.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: origin,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { EventTypesModule } from "@/ee/event-types/event-types.module";
|
||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
||||
import { Editable } from "@/ee/event-types/inputs/enums/editable";
|
||||
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
|
||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
||||
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
|
||||
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
|
||||
@@ -23,7 +25,12 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries";
|
||||
import {
|
||||
EventTypesByViewer,
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
@@ -197,6 +204,77 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update event type locations", async () => {
|
||||
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||
|
||||
const body: UpdateEventTypeInput = {
|
||||
locations,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
const responseLocations = eventTypeLocations.parse(responseBody.data.locations);
|
||||
expect(responseLocations).toBeDefined();
|
||||
expect(responseLocations.length).toEqual(locations.length);
|
||||
expect(responseLocations).toEqual(locations);
|
||||
eventType.locations = responseLocations;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update event type bookingFields", async () => {
|
||||
const bookingFieldName = "location-name";
|
||||
const bookingFields = [
|
||||
{
|
||||
name: bookingFieldName,
|
||||
type: BaseField.radio,
|
||||
label: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Via Bari 10, Roma, 90119, Italy",
|
||||
value: "Via Bari 10, Roma, 90119, Italy",
|
||||
},
|
||||
{
|
||||
label: "Via Reale 28, Roma, 9001, Italy",
|
||||
value: "Via Reale 28, Roma, 9001, Italy",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: Editable.user,
|
||||
required: true,
|
||||
placeholder: "",
|
||||
},
|
||||
];
|
||||
|
||||
const body: UpdateEventTypeInput = {
|
||||
bookingFields,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
const responseBookingFields = eventTypeBookingFields.parse(responseBody.data.bookingFields);
|
||||
expect(responseBookingFields).toBeDefined();
|
||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
||||
eventType.bookingFields = responseBookingFields;
|
||||
});
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-ty
|
||||
import { GetEventTypesData, GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
|
||||
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
|
||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
||||
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";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -33,13 +34,13 @@ import {
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries";
|
||||
import { getEventTypesByViewer } from "@calcom/platform-libraries";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
path: "event-types",
|
||||
version: "2",
|
||||
path: "/v2/event-types",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries";
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -275,11 +275,11 @@ export class UpdateEventTypeInput {
|
||||
// @IsOptional()
|
||||
// parentId?: number;
|
||||
|
||||
// @IsOptional()
|
||||
// @IsArray()
|
||||
// @ValidateNested({ each: true })
|
||||
// @Type(() => BookingField)
|
||||
// bookingFields?: BookingField[];
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BookingField)
|
||||
bookingFields?: BookingField[];
|
||||
|
||||
// @IsString()
|
||||
// @IsOptional()
|
||||
|
||||
@@ -9,8 +9,8 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output";
|
||||
import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output";
|
||||
import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GCalService } from "@/modules/apps/services/gcal.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
@@ -40,8 +41,8 @@ const CALENDAR_SCOPES = [
|
||||
|
||||
// Controller for the GCalConnect Atom
|
||||
@Controller({
|
||||
path: "/gcal",
|
||||
version: "2",
|
||||
path: "/v2/gcal",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Google Calendar")
|
||||
export class GcalController {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
|
||||
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
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";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -14,8 +15,8 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
|
||||
import { userSchemaResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/me",
|
||||
version: "2",
|
||||
path: "/v2/me",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@DocsTags("Me")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
|
||||
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -20,8 +21,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/provider",
|
||||
version: "2",
|
||||
path: "/v2/provider",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Cal provider")
|
||||
export class CalProviderController {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GetScheduleOutput } from "@/ee/schedules/outputs/get-schedule.output";
|
||||
import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
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";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -31,8 +32,8 @@ import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
import { CreateScheduleInput } from "../inputs/create-schedule.input";
|
||||
|
||||
@Controller({
|
||||
path: "schedules",
|
||||
version: "2",
|
||||
path: "/v2/schedules",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
|
||||
@@ -7,13 +7,13 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
|
||||
import { Schedule } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries";
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
transformWorkingHoursForClient,
|
||||
transformAvailabilityForClient,
|
||||
transformDateOverridesForClient,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
@Catch(TRPCError)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { VersionValue } from "@nestjs/common/interfaces";
|
||||
|
||||
import { API_VERSIONS, VERSION_2024_04_15, VERSION_2024_05_21 } from "@calcom/platform-constants";
|
||||
|
||||
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
|
||||
export const VERSION_2024_05_21_VALUE: VersionValue = VERSION_2024_05_21 as unknown as VersionValue;
|
||||
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
import { CapitalizeTimeZone } from "./capitalize-timezone";
|
||||
|
||||
class TestDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@CapitalizeTimeZone()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
describe("CapitalizeTimeZone", () => {
|
||||
it("should capitalize single part time zone correctly", () => {
|
||||
const input = { timeZone: "egypt" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("Egypt");
|
||||
});
|
||||
|
||||
it("should capitalize one-part time zone correctly", () => {
|
||||
const input = { timeZone: "europe/rome" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("Europe/Rome");
|
||||
});
|
||||
|
||||
it("should capitalize multi-part time zone correctly", () => {
|
||||
const input = { timeZone: "america/new_york" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("should capitalize complex time zone correctly", () => {
|
||||
const input = { timeZone: "europe/isle_of_man" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("Europe/Isle_Of_Man");
|
||||
});
|
||||
|
||||
it("should handle already capitalized time zones correctly", () => {
|
||||
const input = { timeZone: "Asia/Tokyo" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("should handle missing time zone correctly", () => {
|
||||
const input = {};
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should capitalize EST at the end of the string", () => {
|
||||
const input = { email: "test@example.com", timeZone: "utc/est" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("UTC/EST");
|
||||
});
|
||||
|
||||
it("should capitalize UTC when surrounded by non-alphabetical characters", () => {
|
||||
const input = { email: "test@example.com", timeZone: "utc/gmt+3_est" };
|
||||
const output = plainToClass(TestDto, input);
|
||||
expect(output.timeZone).toBe("UTC/GMT+3_EST");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Transform } from "class-transformer";
|
||||
|
||||
export function CapitalizeTimeZone(): PropertyDecorator {
|
||||
return Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
const parts = value.split("/");
|
||||
const normalizedParts = parts.map((part) =>
|
||||
part
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("_")
|
||||
);
|
||||
let normalizedTimeZone = normalizedParts.join("/");
|
||||
|
||||
// note(Lauris): regex matching GMT, EST, UTC at the start, end, or surrounded by non-letters and capitalizing them
|
||||
const specialCases = ["GMT", "EST", "UTC"];
|
||||
specialCases.forEach((specialCase) => {
|
||||
const regex = new RegExp(`(^|[^a-zA-Z])(${specialCase})([^a-zA-Z]|$)`, "gi");
|
||||
normalizedTimeZone = normalizedTimeZone.replace(regex, (match, p1, p2, p3) => {
|
||||
return `${p1}${specialCase}${p3}`;
|
||||
});
|
||||
});
|
||||
|
||||
return normalizedTimeZone;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
@@ -29,8 +30,8 @@ import { Stripe } from "stripe";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/billing",
|
||||
version: "2",
|
||||
path: "/v2/billing",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@ApiExcludeController(true)
|
||||
export class BillingController {
|
||||
@@ -122,7 +123,7 @@ export class BillingController {
|
||||
await this.billingService.setSubscriptionForTeam(
|
||||
teamId,
|
||||
subscription,
|
||||
PlatformPlan[plan as keyof typeof PlatformPlan]
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ export class BillingConfigService {
|
||||
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
|
||||
for (const key of planKeys) {
|
||||
this.config.set(
|
||||
PlatformPlan[key as keyof typeof PlatformPlan],
|
||||
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
|
||||
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
@@ -33,8 +34,8 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { Pagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "oauth-clients/:clientId/users",
|
||||
version: "2",
|
||||
path: "/v2/oauth-clients/:clientId/users",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(OAuthClientCredentialsGuard)
|
||||
@DocsTags("Managed users")
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
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 { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
@@ -46,8 +47,8 @@ const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-
|
||||
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
|
||||
|
||||
@Controller({
|
||||
path: "oauth-clients",
|
||||
version: "2",
|
||||
path: "/v2/oauth-clients",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
@@ -33,8 +34,8 @@ import { Response as ExpressResponse } from "express";
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "oauth/:clientId",
|
||||
version: "2",
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
@DocsTags("OAuth - development only")
|
||||
|
||||
@@ -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";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { SlotsService } from "@/modules/slots/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||
@@ -5,14 +6,14 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.2";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.2";
|
||||
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "slots",
|
||||
version: "2",
|
||||
path: "/v2/slots",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Slots")
|
||||
export class SlotsController {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.2";
|
||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "timezones",
|
||||
version: "2",
|
||||
path: "/v2/timezones",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Timezones")
|
||||
export class TimezonesController {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries";
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
@Injectable()
|
||||
export class TimezonesService {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsTimeZone, IsString } from "class-validator";
|
||||
|
||||
@@ -26,6 +27,7 @@ export class CreateManagedUserInput {
|
||||
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
@CapitalizeTimeZone()
|
||||
@ApiProperty({ example: "America/New_York" })
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
@@ -1214,6 +1214,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/connect": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_redirect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/save": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_save",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/check": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_check",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/bookings": {
|
||||
"get": {
|
||||
"operationId": "BookingsController_getBookings",
|
||||
@@ -2104,17 +2211,20 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"slotInterval": {
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"minimumBookingNotice": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"beforeEventBuffer": {
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"afterEventBuffer": {
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2798,6 +2908,12 @@
|
||||
"$ref": "#/components/schemas/EventTypeLocation"
|
||||
}
|
||||
},
|
||||
"bookingFields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BookingField"
|
||||
}
|
||||
},
|
||||
"disableGuests": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2806,13 +2922,16 @@
|
||||
"minimum": 0
|
||||
},
|
||||
"beforeEventBuffer": {
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"afterEventBuffer": {
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"slotInterval": {
|
||||
"type": "number"
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@prisma/client/*": ["@calcom/prisma/client/*"],
|
||||
"@calcom/platform-libraries": ["../../../packages/platform/libraries/index.ts"],
|
||||
"@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"],
|
||||
"@calcom/platform-types": ["../../../packages/platform/types/index.ts"],
|
||||
"@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"]
|
||||
@@ -27,17 +26,13 @@
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"watchOptions": {
|
||||
"watchFile": "fixedPollingInterval",
|
||||
"watchDirectory": "useFsEvents",
|
||||
"fallbackPolling": "dynamicPriority",
|
||||
"synchronousWatchDirectory": true,
|
||||
"excludeDirectories": ["**/node_modules", "dist"]
|
||||
},
|
||||
"exclude": ["./dist", "./node_modules", "next-i18next.config.js"],
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"../../../packages/types/*.d.ts",
|
||||
"../../../packages/platform/libraries/**/*.ts",
|
||||
"../../../packages/platform/libraries/**/*.d.ts",
|
||||
"../../../packages/platform/constants/**/*.ts",
|
||||
"../../../packages/platform/constants/**/*.d.ts",
|
||||
"../../../packages/platform/types/**/*.ts",
|
||||
"../../../packages/platform/types/**/*.d.ts",
|
||||
"../../../packages/platform/utils/**/*.ts",
|
||||
"../../../packages/platform/utils/**/*.d.ts"
|
||||
]
|
||||
"include": ["./**/*.ts", "../../../packages/types/*.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -11,14 +10,11 @@ import {
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { HeadSeo, Icon } from "@calcom/ui";
|
||||
import { Discord } from "@calcom/ui/components/icon/Discord";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTranslations } from "@server/lib/getTranslations";
|
||||
|
||||
enum pageType {
|
||||
ORG = "org",
|
||||
TEAM = "team",
|
||||
@@ -271,13 +267,3 @@ export default function Custom404() {
|
||||
}
|
||||
|
||||
Custom404.PageWrapper = PageWrapper;
|
||||
|
||||
export const getStaticProps = async (context: GetStaticPropsContext) => {
|
||||
const i18n = await getTranslations(context);
|
||||
|
||||
return {
|
||||
props: {
|
||||
i18n,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Typescript class based component for custom-error
|
||||
* @link https://nextjs.org/docs/advanced-features/custom-error-page
|
||||
*/
|
||||
import type { NextPage } from "next";
|
||||
import type { ErrorProps } from "next/error";
|
||||
import React from "react";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
import { ErrorPage } from "@components/error/error-page";
|
||||
|
||||
type NextError = Error & { digest?: string };
|
||||
|
||||
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
|
||||
export type DefaultErrorProps = {
|
||||
error: NextError;
|
||||
reset: () => void; // A function to reset the error boundary
|
||||
};
|
||||
|
||||
type AugmentedError = NextError | HttpError | null;
|
||||
|
||||
type CustomErrorProps = {
|
||||
err?: AugmentedError;
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
} & Omit<ErrorProps, "err" | "statusCode">;
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||
|
||||
const CustomError: NextPage<DefaultErrorProps> = (props) => {
|
||||
const { error } = props;
|
||||
let errorObject: CustomErrorProps = {
|
||||
message: error.message,
|
||||
err: error,
|
||||
};
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
const redactedError = redactError(error);
|
||||
errorObject = {
|
||||
statusCode: error.statusCode,
|
||||
title: redactedError.name,
|
||||
message: redactedError.message,
|
||||
err: {
|
||||
...redactedError,
|
||||
...error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
|
||||
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
|
||||
log.info("errorObject: ", errorObject);
|
||||
|
||||
return (
|
||||
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomError;
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type NextPage } from "next";
|
||||
|
||||
import CustomError, { type DefaultErrorProps } from "./error";
|
||||
|
||||
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<CustomError {...props} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalError;
|
||||
@@ -1,9 +1,10 @@
|
||||
import NotFoundPage from "@pages/404";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
import { getTranslations } from "@server/lib/getTranslations";
|
||||
|
||||
import NotFoundPage from "./404/page";
|
||||
import { WithLayout } from "./layoutHOC";
|
||||
|
||||
const getData = async (context: GetStaticPropsContext) => {
|
||||
const i18n = await getTranslations(context);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/comp
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
@@ -21,6 +22,7 @@ export type AppPageProps = {
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
slug: string;
|
||||
dirName: string | undefined;
|
||||
variant: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
@@ -68,8 +70,11 @@ export const AppPage = ({
|
||||
dependencies,
|
||||
concurrentMeetings,
|
||||
paid,
|
||||
dirName,
|
||||
}: AppPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
@@ -118,6 +123,11 @@ export const AppPage = ({
|
||||
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
|
||||
// Such apps, can only be installed once.
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
|
||||
useEffect(() => {
|
||||
if (searchParams?.get("defaultInstall") === "true") {
|
||||
mutation.mutate({ type, variant, slug, defaultInstall: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
@@ -223,6 +233,7 @@ export const AppPage = ({
|
||||
multiInstall
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
dirName={dirName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -262,6 +273,7 @@ export const AppPage = ({
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
dirName={dirName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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";
|
||||
@@ -26,7 +33,9 @@ export const InstallAppButtonChild = ({
|
||||
multiInstall,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
dirName,
|
||||
paid,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
@@ -36,8 +45,10 @@ export const InstallAppButtonChild = ({
|
||||
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) => {
|
||||
@@ -49,6 +60,18 @@ export const InstallAppButtonChild = ({
|
||||
},
|
||||
});
|
||||
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
|
||||
@@ -56,6 +79,7 @@ export const InstallAppButtonChild = ({
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
color="primary"
|
||||
@@ -72,6 +96,7 @@ export const InstallAppButtonChild = ({
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
onClick={_onClick}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
@@ -83,6 +108,19 @@ export const InstallAppButtonChild = ({
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { FC } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { Avatar, StepCard } from "@calcom/ui";
|
||||
|
||||
type AccountSelectorProps = {
|
||||
avatar?: string;
|
||||
name: string;
|
||||
alreadyInstalled: boolean;
|
||||
onClick: () => void;
|
||||
loading: boolean;
|
||||
testId: string;
|
||||
};
|
||||
|
||||
const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
avatar,
|
||||
alreadyInstalled,
|
||||
name,
|
||||
onClick,
|
||||
loading,
|
||||
testId,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [selected, setSelected] = useState(false);
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={() => {
|
||||
if (!alreadyInstalled && !loading) {
|
||||
setSelected(true);
|
||||
onClick();
|
||||
}
|
||||
}}>
|
||||
<Avatar
|
||||
alt={avatar || ""}
|
||||
imageSrc={avatar || `${CAL_URL}/${avatar}`} // if no image, use default avatar
|
||||
size="sm"
|
||||
/>
|
||||
<div className="text-md pt-0.5 font-medium text-gray-500">
|
||||
{name}
|
||||
{alreadyInstalled ? <span className="ml-1 text-sm text-gray-400">{t("already_installed")}</span> : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PersonalAccountProps = Pick<User, "id" | "avatar" | "name"> & { alreadyInstalled: boolean };
|
||||
|
||||
export type TeamsProp = (Pick<Team, "id" | "name" | "logo"> & {
|
||||
alreadyInstalled: boolean;
|
||||
})[];
|
||||
|
||||
type AccountStepCardProps = {
|
||||
teams: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
onSelect: (id?: number) => void;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAccount, onSelect, loading }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<StepCard>
|
||||
<div className="text-sm font-medium text-gray-400">{t("install_app_on")}</div>
|
||||
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
|
||||
<AccountSelector
|
||||
testId="install-app-button-personal"
|
||||
avatar={personalAccount.avatar ?? ""}
|
||||
name={personalAccount.name ?? ""}
|
||||
alreadyInstalled={personalAccount.alreadyInstalled}
|
||||
onClick={() => onSelect()}
|
||||
loading={loading}
|
||||
/>
|
||||
{teams.map((team) => (
|
||||
<AccountSelector
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logo ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StepCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
||||
import { X } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FC } from "react";
|
||||
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 { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Button, Form } from "@calcom/ui";
|
||||
|
||||
import useAppsData from "@lib/hooks/useAppsData";
|
||||
|
||||
type TFormType = {
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
};
|
||||
|
||||
type ConfigureStepCardProps = {
|
||||
slug: string;
|
||||
userName: string;
|
||||
categories: AppCategories[];
|
||||
credentialId?: number;
|
||||
loading?: boolean;
|
||||
formPortalRef: React.RefObject<HTMLDivElement>;
|
||||
eventTypes: TEventType[] | undefined;
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
handleSetUpLater: () => void;
|
||||
};
|
||||
|
||||
type EventTypeAppSettingsFormProps = Pick<
|
||||
ConfigureStepCardProps,
|
||||
"slug" | "userName" | "categories" | "credentialId" | "loading"
|
||||
> & {
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
|
||||
function EventTypeAppSettingsForm(props, ref) {
|
||||
const { handleDelete, onSubmit, eventType, loading } = props;
|
||||
|
||||
const formMethods = useForm<TFormType>({
|
||||
defaultValues: {
|
||||
metadata: eventType?.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
id={`eventtype-${eventType.id}`}
|
||||
handleSubmit={() => {
|
||||
const data = formMethods.getValues("metadata");
|
||||
onSubmit(data);
|
||||
}}>
|
||||
<div>
|
||||
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
|
||||
<div>
|
||||
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{eventType.title}</span>{" "}
|
||||
<small className="text-subtle hidden font-normal sm:inline">
|
||||
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
|
||||
</small>
|
||||
</div>
|
||||
<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}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
|
||||
loading,
|
||||
formPortalRef,
|
||||
eventTypes,
|
||||
setConfigureStep,
|
||||
handleSetUpLater,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control, getValues } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypes",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
const submitRefs = useRef<Array<React.RefObject<HTMLButtonElement>>>([]);
|
||||
submitRefs.current = fields.map(
|
||||
(_ref, index) => (submitRefs.current[index] = React.createRef<HTMLButtonElement>())
|
||||
);
|
||||
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
|
||||
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
|
||||
fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
|
||||
);
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
|
||||
|
||||
useEffect(() => {
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
|
||||
);
|
||||
if (!fields.some((field) => field.selected)) {
|
||||
setConfigureStep(false);
|
||||
}
|
||||
}, [fields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submit && allUpdated && mainForSubmitRef.current) {
|
||||
mainForSubmitRef.current?.click();
|
||||
setSubmit(false);
|
||||
}
|
||||
}, [submit, allUpdated, getValues, mainForSubmitRef]);
|
||||
|
||||
return (
|
||||
formPortalRef?.current &&
|
||||
createPortal(
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
field.selected && (
|
||||
<EventTypeAppSettingsForm
|
||||
key={field.fieldId}
|
||||
eventType={field}
|
||||
loading={loading}
|
||||
handleDelete={() => {
|
||||
const eventMetadataDb = eventTypes?.find(
|
||||
(eventType) => eventType.id == field.id
|
||||
)?.metadata;
|
||||
update(index, { ...field, selected: false, metadata: eventMetadataDb });
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
update(index, { ...field, metadata: data });
|
||||
setUpdatedEventTypesStatus((prev) =>
|
||||
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
|
||||
);
|
||||
}}
|
||||
ref={submitRefs.current[index]}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
|
||||
Save
|
||||
</button>
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
type="button"
|
||||
data-testid="configure-step-save"
|
||||
onClick={() => {
|
||||
submitRefs.current.reverse().map((ref) => ref.current?.click());
|
||||
setSubmit(true);
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
formPortalRef?.current
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { ScrollableArea, Badge, Button } from "@calcom/ui";
|
||||
|
||||
type EventTypesCardProps = {
|
||||
userName: string;
|
||||
setConfigureStep: Dispatch<SetStateAction<boolean>>;
|
||||
handleSetUpLater: () => void;
|
||||
};
|
||||
|
||||
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
|
||||
setConfigureStep,
|
||||
userName,
|
||||
handleSetUpLater,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { control } = useFormContext<TEventTypesForm>();
|
||||
const { fields, update } = useFieldArray({
|
||||
control,
|
||||
name: "eventTypes",
|
||||
keyName: "fieldId",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:border-subtle bg-default mt-10 border dark:bg-black sm:rounded-md">
|
||||
<ScrollableArea className="rounded-md">
|
||||
<ul className="border-subtle max-h-97 !static w-full divide-y">
|
||||
{fields.map((field, index) => (
|
||||
<EventTypeCard
|
||||
handleSelect={() => update(index, { ...field, selected: !field.selected })}
|
||||
userName={userName}
|
||||
key={field.fieldId}
|
||||
{...field}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
data-testid="save-event-types"
|
||||
onClick={() => {
|
||||
setConfigureStep(true);
|
||||
}}
|
||||
disabled={!fields.some((field) => field.selected === true)}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void };
|
||||
|
||||
const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
id,
|
||||
metadata,
|
||||
length,
|
||||
selected,
|
||||
slug,
|
||||
handleSelect,
|
||||
team,
|
||||
userName,
|
||||
}) => {
|
||||
const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata);
|
||||
const durations =
|
||||
parsedMetaData.success &&
|
||||
parsedMetaData.data?.multipleDuration &&
|
||||
Boolean(parsedMetaData.data?.multipleDuration.length)
|
||||
? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort()
|
||||
: [length];
|
||||
return (
|
||||
<div
|
||||
data-testid={`select-event-type-${id}`}
|
||||
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
|
||||
onClick={() => handleSelect()}>
|
||||
<input
|
||||
id={`${id}`}
|
||||
checked={selected}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
|
||||
<li>
|
||||
<div>
|
||||
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{title}</span>{" "}
|
||||
<small className="text-subtle hidden font-normal sm:inline">
|
||||
/{team ? team.slug : userName}/{slug}
|
||||
</small>
|
||||
</div>
|
||||
{Boolean(description) && (
|
||||
<div className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-row flex-wrap gap-2">
|
||||
{Boolean(durations.length) &&
|
||||
durations.map((duration) => (
|
||||
<Badge key={`event-type-${id}-duration-${duration}`} variant="gray" startIcon="clock">
|
||||
{duration}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type StepHeaderProps = {
|
||||
children?: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
export const StepHeader: FC<StepHeaderProps> = ({ children, title, subtitle }) => {
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium capitalize leading-7">{title}</p>
|
||||
|
||||
<p className="text-subtle font-sans text-sm font-normal">{subtitle}</p>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
|
||||
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -35,6 +37,14 @@ import {
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
@@ -87,7 +97,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
@@ -220,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
if (isPast && isPending && !isConfirmed) {
|
||||
if (isBookingInPast && isPending && !isConfirmed) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||
}
|
||||
|
||||
@@ -278,9 +288,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
|
||||
const showCheckRecordingButton =
|
||||
isPast &&
|
||||
isBookingInPast &&
|
||||
isConfirmed &&
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
@@ -298,7 +308,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
];
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -436,7 +453,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={booking.attendees}
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
@@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
@@ -654,13 +673,215 @@ const FirstAttendee = ({
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||
type NoShowProps = {
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
};
|
||||
|
||||
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [noShow, setNoShow] = useState(noShowAttendee);
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(
|
||||
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
|
||||
x: name || email,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function toggleNoShow({
|
||||
attendee,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendee: { email: string; noShow: boolean };
|
||||
bookingUid: string;
|
||||
}) {
|
||||
noShowMutation.mutate({ bookingUid, attendees: [attendee] });
|
||||
setNoShow(!noShow);
|
||||
}
|
||||
|
||||
return (
|
||||
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
|
||||
{name || email}
|
||||
</a>
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="guest"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<s>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
</s>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${email}`}
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<a href={`mailto:${email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
copyToClipboard(email);
|
||||
setOpenDropdown(false);
|
||||
showToast(t("email_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
{noShow ? (
|
||||
<DropdownItem
|
||||
data-testid="unmark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye">
|
||||
{t("unmark_as_no_show")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<DropdownItem
|
||||
data-testid="mark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye-off">
|
||||
{t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type GroupedAttendeeProps = {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
const { bookingUid } = groupedAttendeeProps;
|
||||
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
const { t } = useLocale();
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("no_show_updated"), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit } = useForm<{
|
||||
attendees: AttendeeProps[];
|
||||
}>({
|
||||
defaultValues: {
|
||||
attendees,
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: "attendees",
|
||||
});
|
||||
|
||||
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
|
||||
const filteredData = data.attendees.slice(1);
|
||||
noShowMutation.mutate({ bookingUid, attendees: filteredData });
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
||||
{t("plus_more", { count: attendees.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">
|
||||
{t("mark_as_no_show_title")}
|
||||
</DropdownMenuLabel>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.slice(1).map((field, index) => (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="pr-8 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<span className={value ? "line-through" : ""}>{field.email}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className=" flex justify-end p-2">
|
||||
<Button
|
||||
data-testid="update-no-show"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)();
|
||||
}}>
|
||||
{t("mark_as_no_show_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -668,17 +889,23 @@ const DisplayAttendees = ({
|
||||
attendees,
|
||||
user,
|
||||
currentEmail,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
user: UserProps | null;
|
||||
currentEmail?: string | null;
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
attendees.sort((a, b) => a.id - b.id);
|
||||
|
||||
return (
|
||||
<div className="text-emphasis text-sm">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} />
|
||||
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="text-emphasis inline-block text-sm"> {t("and")} </div>
|
||||
@@ -686,13 +913,13 @@ const DisplayAttendees = ({
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
<p key={attendee.email}>
|
||||
<Attendee {...attendee} />
|
||||
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</p>
|
||||
))}>
|
||||
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
|
||||
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Attendee {...attendees[1]} />
|
||||
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,11 +19,12 @@ interface CustomEventTypeModalFormProps {
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
defaultValue: string;
|
||||
isNameFieldSplit: boolean;
|
||||
}
|
||||
|
||||
const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const { placeHolder, close, setValue, event } = props;
|
||||
const { placeHolder, close, setValue, event, isNameFieldSplit } = props;
|
||||
const { register, handleSubmit, watch, getValues } = useFormContext<FormValues>();
|
||||
const onSubmit: SubmitHandler<FormValues> = (data) => {
|
||||
setValue(data.customEventName);
|
||||
@@ -68,10 +69,26 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
|
||||
<p className="text-emphasis">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser first name}`}</p>
|
||||
<p className="text-emphasis">{t("organizer_first_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
{isNameFieldSplit && (
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler first name}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_first_name")}</p>
|
||||
</div>
|
||||
)}
|
||||
{isNameFieldSplit && (
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler last name}`}</p>
|
||||
<p className="text-emphasis">{t("scheduler_last_name")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
|
||||
<p className="text-emphasis">{t("location_info")}</p>
|
||||
@@ -101,12 +118,13 @@ interface CustomEventTypeModalProps {
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
isNameFieldSplit: boolean;
|
||||
}
|
||||
|
||||
const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { defaultValue, placeHolder, close, setValue, event } = props;
|
||||
const { defaultValue, placeHolder, close, setValue, event, isNameFieldSplit } = props;
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
@@ -128,6 +146,7 @@ const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
|
||||
setValue={setValue}
|
||||
placeHolder={placeHolder}
|
||||
defaultValue={defaultValue}
|
||||
isNameFieldSplit={isNameFieldSplit}
|
||||
/>
|
||||
</FormProvider>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -65,6 +65,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
bookingFields[name] = `${name} input`;
|
||||
});
|
||||
|
||||
const nameBookingField = formMethods.getValues().bookingFields.find((field) => field.name === "name");
|
||||
const isSplit = (nameBookingField && nameBookingField.variant === "firstAndLastName") ?? false;
|
||||
|
||||
const eventNameObject: EventNameObjectType = {
|
||||
attendeeName: t("scheduler"),
|
||||
eventType: formMethods.getValues("title"),
|
||||
@@ -564,6 +567,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
setValue={(val: string) => formMethods.setValue("eventName", val, { shouldDirty: true })}
|
||||
defaultValue={formMethods.getValues("eventName")}
|
||||
placeHolder={eventNamePlaceholder}
|
||||
isNameFieldSplit={isSplit}
|
||||
event={eventNameObject}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@ import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
|
||||
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
@@ -13,6 +12,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
||||
|
||||
import useAppsData from "@lib/hooks/useAppsData";
|
||||
|
||||
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
|
||||
@@ -28,51 +29,8 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
|
||||
const notInstalledApps =
|
||||
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
|
||||
const allAppsData = formMethods.watch("metadata")?.apps || {};
|
||||
|
||||
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||
formMethods.setValue(
|
||||
"metadata",
|
||||
{
|
||||
...formMethods.getValues("metadata"),
|
||||
apps: _allAppsData,
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
|
||||
return function (key) {
|
||||
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
|
||||
if (key) {
|
||||
return appData[key as keyof typeof appData];
|
||||
}
|
||||
return appData;
|
||||
};
|
||||
};
|
||||
|
||||
const eventTypeFormMetadata = formMethods.getValues("metadata");
|
||||
|
||||
const getAppDataSetter = (
|
||||
appId: EventTypeAppsList,
|
||||
appCategories: string[],
|
||||
credentialId?: number
|
||||
): SetAppData => {
|
||||
return function (key, value) {
|
||||
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
|
||||
const appData = allAppsDataFromForm[appId];
|
||||
setAllAppsData({
|
||||
...allAppsDataFromForm,
|
||||
[appId]: {
|
||||
...appData,
|
||||
[key]: value,
|
||||
credentialId,
|
||||
appCategories,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData();
|
||||
|
||||
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
|
||||
@@ -323,7 +323,7 @@ function EventTypeSingleLayout({
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:hover:bg-muted hidden cursor-pointer items-center rounded-md",
|
||||
formMethods.watch("hidden") ? "px-2" : "",
|
||||
formMethods.watch("hidden") ? "pl-2" : "",
|
||||
"lg:flex"
|
||||
)}>
|
||||
{formMethods.watch("hidden") && (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Card, Icon } from "@calcom/ui";
|
||||
|
||||
import { helpCards } from "@lib/settings/platform/utils";
|
||||
|
||||
export const HelpCards = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="grid-col-1 mb-4 grid gap-2 md:grid-cols-3">
|
||||
{helpCards.map((card) => {
|
||||
return (
|
||||
<div key={card.title}>
|
||||
<Card
|
||||
icon={<Icon name={card.icon} className="h-5 w-5 text-green-700" />}
|
||||
variant={card.variant}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
actionButton={{
|
||||
href: `${card.actionButton.href}`,
|
||||
child: `${card.actionButton.child}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -195,7 +195,11 @@ function getThemeProviderProps(props: {
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
|
||||
if ((isBookingPageThemeSupportRequired || props.isEmbedMode) && !props.themeBasis) {
|
||||
if (
|
||||
!process.env.NEXT_PUBLIC_IS_E2E &&
|
||||
(isBookingPageThemeSupportRequired || props.isEmbedMode) &&
|
||||
!props.themeBasis
|
||||
) {
|
||||
console.warn(
|
||||
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
||||
);
|
||||
|
||||
@@ -215,7 +215,7 @@ function getThemeProviderProps({
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const themeBasis = props.themeBasis;
|
||||
|
||||
if ((isBookingPageThemeSupportRequired || isEmbedMode) && !themeBasis) {
|
||||
if (!process.env.NEXT_PUBLIC_IS_E2E && (isBookingPageThemeSupportRequired || isEmbedMode) && !themeBasis) {
|
||||
console.warn(
|
||||
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
|
||||
const useAppsData = () => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const allAppsData = formMethods.watch("metadata")?.apps || {};
|
||||
|
||||
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||
formMethods.setValue(
|
||||
"metadata",
|
||||
{
|
||||
...formMethods.getValues("metadata"),
|
||||
apps: _allAppsData,
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
|
||||
return function (key) {
|
||||
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
|
||||
if (key) {
|
||||
return appData[key as keyof typeof appData];
|
||||
}
|
||||
return appData;
|
||||
};
|
||||
};
|
||||
|
||||
const eventTypeFormMetadata = formMethods.getValues("metadata");
|
||||
|
||||
const getAppDataSetter = (
|
||||
appId: EventTypeAppsList,
|
||||
appCategories: string[],
|
||||
credentialId?: number
|
||||
): SetAppData => {
|
||||
return function (key, value) {
|
||||
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
|
||||
const appData = allAppsDataFromForm[appId];
|
||||
setAllAppsData({
|
||||
...allAppsDataFromForm,
|
||||
[appId]: {
|
||||
...appData,
|
||||
[key]: value,
|
||||
credentialId,
|
||||
appCategories,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata };
|
||||
};
|
||||
|
||||
export default useAppsData;
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { IconName } from "@calcom/ui";
|
||||
|
||||
type IndividualPlatformPlan = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
};
|
||||
|
||||
type HelpCardInfo = {
|
||||
icon: IconName;
|
||||
variant: "basic" | "ProfileCard" | "SidebarCard" | null;
|
||||
title: string;
|
||||
description: string;
|
||||
actionButton: {
|
||||
href: string;
|
||||
child: string;
|
||||
};
|
||||
};
|
||||
|
||||
// if pricing or plans change in future modify this
|
||||
export const platformPlans: IndividualPlatformPlan[] = [
|
||||
{
|
||||
plan: "Starter",
|
||||
description:
|
||||
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 99,
|
||||
includes: [
|
||||
"Up to 100 bookings a month",
|
||||
"Community Support",
|
||||
"Cal Atoms (React Library)",
|
||||
"Platform APIs",
|
||||
"Admin APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Essentials",
|
||||
description:
|
||||
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 299,
|
||||
includes: [
|
||||
"Up to 500 bookings a month. $0,60 overage beyond",
|
||||
"Everything in Starter",
|
||||
"Cal Atoms (React Library)",
|
||||
"User Management and Analytics",
|
||||
"Technical Account Manager and Onboarding Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Scale",
|
||||
description:
|
||||
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
|
||||
pricing: 2499,
|
||||
includes: [
|
||||
"Up to 5000 bookings a month. $0.50 overage beyond",
|
||||
"Everything in Essentials",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
"Help with Credentials Verification (Zoom, Google App Store)",
|
||||
"Expedited features and integrations",
|
||||
"SLA (99.999% uptime)",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Enterprise",
|
||||
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
|
||||
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
|
||||
},
|
||||
];
|
||||
|
||||
export const helpCards: HelpCardInfo[] = [
|
||||
{
|
||||
icon: "rocket",
|
||||
title: "Try our Platform Starter Kit",
|
||||
description:
|
||||
"If you are building a marketplace or platform from scratch, our Platform Starter Kit has everything you need.",
|
||||
variant: "basic",
|
||||
actionButton: {
|
||||
href: "https://experts.cal.com",
|
||||
child: "Try the Demo",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "github",
|
||||
title: "Get the Source code",
|
||||
description:
|
||||
"Our Platform Starter Kit is being used in production by Cal.com itself. You can find the ready-to-rock source code on GitHub.",
|
||||
variant: "basic",
|
||||
actionButton: {
|
||||
href: "https://github.com/calcom/examples",
|
||||
child: "GitHub",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "calendar-check-2",
|
||||
title: "Contact us",
|
||||
description:
|
||||
"Book our engineering team for a 15 minute onboarding call and debug a problem. Please come prepared with questions.",
|
||||
variant: "basic",
|
||||
actionButton: {
|
||||
href: "https://i.cal.com/platform",
|
||||
child: "Schedule a call",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
@@ -159,8 +160,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
}
|
||||
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
return {
|
||||
props: {
|
||||
orgSlug: currentOrgDomain,
|
||||
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
||||
profile,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import Success from "./bookings-single-view";
|
||||
|
||||
function mockedSuccessComponentProps(props: Partial<React.ComponentProps<typeof Success>>) {
|
||||
return {
|
||||
eventType: {
|
||||
id: 1,
|
||||
title: "Event Title",
|
||||
description: "",
|
||||
locations: null,
|
||||
length: 15,
|
||||
userId: null,
|
||||
eventName: "d",
|
||||
timeZone: null,
|
||||
recurringEvent: null,
|
||||
requiresConfirmation: false,
|
||||
disableGuests: false,
|
||||
seatsPerTimeSlot: null,
|
||||
seatsShowAttendees: null,
|
||||
seatsShowAvailabilityCount: null,
|
||||
schedulingType: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
successRedirectUrl: null,
|
||||
customInputs: [],
|
||||
team: null,
|
||||
workflows: [],
|
||||
hosts: [],
|
||||
users: [],
|
||||
owner: null,
|
||||
isDynamic: false,
|
||||
periodStartDate: "1",
|
||||
periodEndDate: "1",
|
||||
metadata: null,
|
||||
bookingFields: [] as unknown as [] & z.BRAND<"HAS_SYSTEM_FIELDS">,
|
||||
},
|
||||
profile: {
|
||||
name: "John",
|
||||
email: null,
|
||||
theme: null,
|
||||
brandColor: null,
|
||||
darkBrandColor: null,
|
||||
slug: null,
|
||||
},
|
||||
bookingInfo: {
|
||||
uid: "uid",
|
||||
metadata: null,
|
||||
customInputs: [],
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
id: 1,
|
||||
user: null,
|
||||
eventType: null,
|
||||
seatsReferences: [],
|
||||
userPrimaryEmail: null,
|
||||
eventTypeId: null,
|
||||
title: "Booking Title",
|
||||
description: null,
|
||||
location: null,
|
||||
recurringEventId: null,
|
||||
smsReminderNumber: "0",
|
||||
cancellationReason: null,
|
||||
rejectionReason: null,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
attendees: [],
|
||||
responses: {
|
||||
name: "John",
|
||||
},
|
||||
},
|
||||
orgSlug: null,
|
||||
userTimeFormat: 12,
|
||||
requiresLoginToUpdate: false,
|
||||
themeBasis: "dark",
|
||||
hideBranding: false,
|
||||
recurringBookings: null,
|
||||
trpcState: {
|
||||
queries: [],
|
||||
mutations: [],
|
||||
},
|
||||
dynamicEventName: "Event Title",
|
||||
paymentStatus: null,
|
||||
...props,
|
||||
} satisfies React.ComponentProps<typeof Success>;
|
||||
}
|
||||
|
||||
describe("Success Component", () => {
|
||||
it("renders HeadSeo correctly", () => {
|
||||
vi.mocked(getOrgFullOrigin).mockImplementation((text: string | null) => `${text}.cal.local`);
|
||||
vi.mocked(useRouterQuery).mockReturnValue({
|
||||
uid: "uid",
|
||||
});
|
||||
vi.mocked(useSession).mockReturnValue({
|
||||
update: vi.fn(),
|
||||
status: "authenticated",
|
||||
data: {
|
||||
hasValidLicense: true,
|
||||
upId: "1",
|
||||
expires: "1",
|
||||
user: {
|
||||
name: "John",
|
||||
id: 1,
|
||||
profile: {
|
||||
id: null,
|
||||
upId: "1",
|
||||
username: null,
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockObject = {
|
||||
props: mockedSuccessComponentProps({
|
||||
orgSlug: "org1",
|
||||
}),
|
||||
};
|
||||
|
||||
render(<Success {...mockObject.props} />);
|
||||
|
||||
const expectedTitle = `booking_confirmed`;
|
||||
const expectedDescription = expectedTitle;
|
||||
expect(HeadSeo).toHaveBeenCalledWith(
|
||||
{
|
||||
origin: `${mockObject.props.orgSlug}.cal.local`,
|
||||
title: expectedTitle,
|
||||
description: expectedDescription,
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import type { nameObjectSchema } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import type { ConfigType } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useIsBackgroundTransparent,
|
||||
@@ -60,12 +61,10 @@ import {
|
||||
EmptyScreen,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import CancelBooking from "@components/booking/CancelBooking";
|
||||
import EventReservationSchema from "@components/schemas/EventReservationSchema";
|
||||
import PageWrapper from "@calcom/web/components/PageWrapper";
|
||||
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
|
||||
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
|
||||
import { timeZone } from "@calcom/web/lib/clock";
|
||||
|
||||
import type { PageProps } from "./bookings-single-view.getServerSideProps";
|
||||
|
||||
@@ -109,7 +108,7 @@ export default function Success(props: PageProps) {
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { eventType, bookingInfo, requiresLoginToUpdate } = props;
|
||||
const { eventType, bookingInfo, requiresLoginToUpdate, orgSlug } = props;
|
||||
|
||||
const {
|
||||
allRemainingBookings,
|
||||
@@ -377,7 +376,7 @@ export default function Success(props: PageProps) {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<HeadSeo title={title} description={title} />
|
||||
<HeadSeo origin={getOrgFullOrigin(orgSlug)} title={title} description={title} />
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
||||
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { memo, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
@@ -16,7 +17,7 @@ import { DuplicateDialog } from "@calcom/features/eventtypes/components/Duplicat
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -109,13 +110,10 @@ const querySchema = z.object({
|
||||
|
||||
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||
const { eventTypeGroups } = props;
|
||||
const orgBranding = useOrgBranding();
|
||||
const tabs = eventTypeGroups.map((item) => ({
|
||||
name: item.profile.name ?? "",
|
||||
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
|
||||
avatar: orgBranding
|
||||
? `${orgBranding.fullDomain}${item.teamId ? "/team" : ""}/${item.profile.slug}/avatar.png`
|
||||
: item.profile.image ?? `${WEBAPP_URL + (item.teamId && "/team")}/${item.profile.slug}/avatar.png`,
|
||||
avatar: item.profile.image,
|
||||
}));
|
||||
const { data } = useTypedQuery(querySchema);
|
||||
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
|
||||
@@ -949,12 +947,15 @@ const EventTypesPage: React.FC & {
|
||||
getLayout?: AppProps["Component"]["getLayout"];
|
||||
} = () => {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { open } = useIntercom();
|
||||
const { data: user } = useMeQuery();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
const orgBranding = useOrgBranding();
|
||||
const routerQuery = useRouterQuery();
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
const router = useRouter();
|
||||
|
||||
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
|
||||
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
|
||||
@@ -963,6 +964,20 @@ const EventTypesPage: React.FC & {
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams?.get("openIntercom") === "true") {
|
||||
open();
|
||||
}
|
||||
/**
|
||||
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding.
|
||||
* Adding this redirection logic here as well to ensure the user is redirected to the correct redirectUrl.
|
||||
*/
|
||||
const redirectUrl = localStorage.getItem("onBoardingRedirect");
|
||||
localStorage.removeItem("onBoardingRedirect");
|
||||
redirectUrl && router.push(redirectUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from "react";
|
||||
import { vi, afterEach } from "vitest";
|
||||
|
||||
global.React = React;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// Mock all modules that are used in multiple tests for modules
|
||||
// We don't intend to provide the mock implementation here. They should be provided by respective tests.
|
||||
// But it makes it super easy to start testing any module view without worrying about mocking the dependencies.
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/BookingPageTagManager", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/locations", () => ({
|
||||
DailyLocationType: "daily",
|
||||
guessEventLocationType: vi.fn(),
|
||||
getSuccessPageLocationMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/utils", () => ({
|
||||
getEventTypeAppData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/core/event", () => ({
|
||||
getEventName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({
|
||||
getOrgFullOrigin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/eventtypes/components", () => ({
|
||||
EventTypeDescriptionLazy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/embed-core/embed-iframe", () => {
|
||||
return {
|
||||
useIsBackgroundTransparent: vi.fn(),
|
||||
useIsEmbed: vi.fn(),
|
||||
useEmbedNonStylesConfig: vi.fn(),
|
||||
useEmbedStyles: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/bookings/components/event-meta/Price", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/bookings/lib/SystemField", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => {
|
||||
return {
|
||||
DEFAULT_LIGHT_BRAND_COLOR: "DEFAULT_LIGHT_BRAND_COLOR",
|
||||
DEFAULT_DARK_BRAND_COLOR: "DEFAULT_DARK_BRAND_COLOR",
|
||||
BOOKER_NUMBER_OF_DAYS_TO_LOAD: 1,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/date-fns", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/getBrandColours", () => {
|
||||
return {
|
||||
default: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => {
|
||||
return {
|
||||
useCompatSearchParams: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => {
|
||||
return {
|
||||
useLocale: vi.fn().mockReturnValue({
|
||||
t: vi.fn().mockImplementation((text: string) => {
|
||||
return text;
|
||||
}),
|
||||
i18n: {
|
||||
language: "en",
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useRouterQuery", () => {
|
||||
return {
|
||||
useRouterQuery: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useTheme", () => {
|
||||
return {
|
||||
default: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/recurringStrings", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/recurringStrings", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/prisma/zod-utils", () => ({
|
||||
BookerLayouts: {
|
||||
MONTH_VIEW: "month",
|
||||
},
|
||||
EventTypeMetaDataSchema: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
bookingMetadataSchema: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/react", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
public: {
|
||||
submitRating: {
|
||||
useMutation: vi.fn(),
|
||||
},
|
||||
noShow: {
|
||||
useMutation: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui", () => ({
|
||||
HeadSeo: vi.fn(),
|
||||
useCalcomTheme: vi.fn(),
|
||||
Icon: vi.fn(),
|
||||
UnpublishedEntity: vi.fn(),
|
||||
UserAvatar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/web/components/PageWrapper", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/web/components/booking/CancelBooking", () => ({}));
|
||||
|
||||
vi.mock("@calcom/web/components/schemas/EventReservationSchema", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/web/lib/clock", () => ({
|
||||
timeZone: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./bookings-single-view.getServerSideProps", () => ({}));
|
||||
|
||||
vi.mock("@calcom/lib/webstorage", () => ({
|
||||
localStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/timeFormat", () => ({
|
||||
detectBrowserTimeFormat: vi.fn(),
|
||||
isBrowserLocale24h: vi.fn(),
|
||||
getIs24hClockFromLocalStorage: vi.fn(),
|
||||
}));
|
||||
@@ -50,6 +50,7 @@ export type UserPageProps = {
|
||||
considerUnpublished: boolean;
|
||||
orgSlug?: string | null;
|
||||
name?: string | null;
|
||||
teamSlug?: string | null;
|
||||
};
|
||||
eventTypes: ({
|
||||
descriptionAsSafeHTML: string;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import UserPage from "./users-public-view";
|
||||
|
||||
function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof UserPage>>) {
|
||||
return {
|
||||
trpcState: {
|
||||
mutations: [],
|
||||
queries: [],
|
||||
},
|
||||
themeBasis: "dark",
|
||||
safeBio: "My Bio",
|
||||
profile: {
|
||||
name: "John Doe",
|
||||
image: "john-profile-url",
|
||||
theme: "dark",
|
||||
brandColor: "red",
|
||||
darkBrandColor: "black",
|
||||
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
|
||||
allowSEOIndexing: true,
|
||||
username: "john",
|
||||
},
|
||||
users: [
|
||||
{
|
||||
name: "John Doe",
|
||||
username: "john",
|
||||
avatarUrl: "john-user-url",
|
||||
bio: "",
|
||||
verified: false,
|
||||
profile: {
|
||||
upId: "1",
|
||||
id: 1,
|
||||
username: "john",
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
markdownStrippedBio: "My Bio",
|
||||
entity: {
|
||||
considerUnpublished: false,
|
||||
...(props.entity ?? null),
|
||||
},
|
||||
eventTypes: [],
|
||||
} satisfies React.ComponentProps<typeof UserPage>;
|
||||
}
|
||||
|
||||
describe("UserPage Component", () => {
|
||||
it("should render HeadSeo with correct props", () => {
|
||||
const mockData = {
|
||||
props: mockedUserPageComponentProps({
|
||||
entity: {
|
||||
considerUnpublished: false,
|
||||
orgSlug: "org1",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(getOrgFullOrigin).mockImplementation((orgSlug: string | null) => {
|
||||
return `${orgSlug}.cal.local`;
|
||||
});
|
||||
|
||||
vi.mocked(useRouterQuery).mockReturnValue({
|
||||
uid: "uid",
|
||||
});
|
||||
|
||||
render(<UserPage {...mockData.props} />);
|
||||
|
||||
const expectedDescription = mockData.props.markdownStrippedBio;
|
||||
const expectedTitle = expectedDescription;
|
||||
expect(HeadSeo).toHaveBeenCalledWith(
|
||||
{
|
||||
origin: `${mockData.props.entity.orgSlug}.cal.local`,
|
||||
title: `${mockData.props.profile.name}`,
|
||||
description: expectedDescription,
|
||||
meeting: {
|
||||
profile: {
|
||||
name: mockData.props.profile.name,
|
||||
image: mockData.props.users[0].avatarUrl,
|
||||
},
|
||||
title: expectedTitle,
|
||||
users: [
|
||||
{
|
||||
name: mockData.props.users[0].name,
|
||||
username: mockData.props.users[0].username,
|
||||
},
|
||||
],
|
||||
},
|
||||
nextSeoProps: {
|
||||
nofollow: !mockData.props.profile.allowSEOIndexing,
|
||||
noindex: !mockData.props.profile.allowSEOIndexing,
|
||||
},
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -59,10 +60,10 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
|
||||
const isEventListEmpty = eventTypes.length === 0;
|
||||
const isOrg = !!user?.profile?.organization;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
origin={getOrgFullOrigin(entity.orgSlug ?? null)}
|
||||
title={profile.name}
|
||||
description={markdownStrippedBio}
|
||||
meeting={{
|
||||
@@ -125,7 +126,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
<div
|
||||
key={type.id}
|
||||
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
|
||||
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
|
||||
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0">
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranscription } from "@daily-co/daily-react";
|
||||
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
|
||||
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { TRANSCRIPTION_STARTED_ICON, TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const CalAiTranscribe = () => {
|
||||
@@ -29,8 +29,8 @@ export const CalAiTranscribe = () => {
|
||||
transcription: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop transcription",
|
||||
iconPath: `${WEBAPP_URL}/sparkles-red.svg`,
|
||||
iconPathDarkMode: `${WEBAPP_URL}/sparkles-red.svg`,
|
||||
iconPath: TRANSCRIPTION_STARTED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,8 @@ export const CalAiTranscribe = () => {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: `${WEBAPP_URL}/sparkles`,
|
||||
iconPathDarkMode: `${WEBAPP_URL}/sparkles`,
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,7 @@ export const CalAiTranscribe = () => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id="cal-ai-thing"
|
||||
id="cal-ai-transcription"
|
||||
style={{
|
||||
textShadow: "0 0 20px black, 0 0 20px black, 0 0 20px black",
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,8 @@ 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, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { TRANSCRIPTION_STOPPED_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";
|
||||
@@ -52,8 +53,8 @@ export default function JoinCall(props: PageProps) {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: `${WEBAPP_URL}/sparkles.svg`,
|
||||
iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`,
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -87,7 +88,7 @@ export default function JoinCall(props: PageProps) {
|
||||
<meta property="twitter:description" content={t("quick_video_meeting")} />
|
||||
</Head>
|
||||
<DailyProvider callObject={daily}>
|
||||
<div className="mx-auto" style={{ zIndex: 2, position: "absolute", bottom: 60, width: "100%" }}>
|
||||
<div className="mx-auto" style={{ zIndex: 2, position: "absolute", bottom: 100, width: "100%" }}>
|
||||
<CalAiTranscribe />
|
||||
</div>
|
||||
<div style={{ zIndex: 2, position: "relative" }}>
|
||||
|
||||
+23
-23
@@ -264,6 +264,29 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
{
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Needed due to the introduction of dotted usernames
|
||||
@@ -327,29 +350,6 @@ const nextConfig = {
|
||||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
},
|
||||
{
|
||||
source: "/forms/:formQuery*",
|
||||
destination: "/apps/routing-forms/routing-link/:formQuery*",
|
||||
},
|
||||
{
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
],
|
||||
|
||||
/* TODO: have these files being served from another deployment or CDN {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.1.1",
|
||||
"version": "4.1.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -17,7 +17,8 @@
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
|
||||
"translate-locales": "ts-node scripts/check-missing-translations.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18",
|
||||
|
||||
@@ -80,7 +80,10 @@ const triggerWebhook = async ({
|
||||
...evt,
|
||||
downloadLink,
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
|
||||
log.error(
|
||||
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
|
||||
safeStringify(e)
|
||||
);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
@@ -53,6 +53,7 @@ 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}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Head from "next/head";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl";
|
||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppMeta } from "@calcom/types/App";
|
||||
import { Form, Steps, showToast } from "@calcom/ui";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard";
|
||||
import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard";
|
||||
import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard";
|
||||
import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard";
|
||||
import { StepHeader } from "@components/apps/installation/StepHeader";
|
||||
|
||||
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
|
||||
Pick<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type TEventTypesForm = {
|
||||
eventTypes: TEventType[];
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
AppOnboardingSteps.ACCOUNTS_STEP,
|
||||
AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
AppOnboardingSteps.CONFIGURE_STEP,
|
||||
] as const;
|
||||
const MAX_NUMBER_OF_STEPS = STEPS.length;
|
||||
|
||||
type StepType = (typeof STEPS)[number];
|
||||
|
||||
type StepObj = Record<
|
||||
StepType,
|
||||
{
|
||||
getTitle: (appName: string) => string;
|
||||
getDescription: (appName: string) => string;
|
||||
stepNumber: number;
|
||||
}
|
||||
>;
|
||||
|
||||
type OnboardingPageProps = {
|
||||
appMetadata: AppMeta;
|
||||
step: StepType;
|
||||
teams: TeamsProp;
|
||||
personalAccount: PersonalAccountProps;
|
||||
eventTypes?: TEventType[];
|
||||
userName: string;
|
||||
credentialId?: number;
|
||||
};
|
||||
|
||||
const OnboardingPage = ({
|
||||
step,
|
||||
teams,
|
||||
personalAccount,
|
||||
appMetadata,
|
||||
eventTypes,
|
||||
userName,
|
||||
credentialId,
|
||||
}: OnboardingPageProps) => {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const STEPS_MAP: StepObj = {
|
||||
[AppOnboardingSteps.ACCOUNTS_STEP]: {
|
||||
getTitle: () => `${t("select_account_header")}`,
|
||||
getDescription: (appName) => `${t("select_account_description", { appName })}`,
|
||||
stepNumber: 1,
|
||||
},
|
||||
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
|
||||
getTitle: () => `${t("select_event_types_header")}`,
|
||||
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
|
||||
stepNumber: 2,
|
||||
},
|
||||
[AppOnboardingSteps.CONFIGURE_STEP]: {
|
||||
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
|
||||
getDescription: () => `${t("configure_app_description")}`,
|
||||
stepNumber: 3,
|
||||
},
|
||||
} as const;
|
||||
const [configureStep, setConfigureStep] = useState(false);
|
||||
const [isSelectingAccount, setIsSelectingAccount] = useState(false);
|
||||
|
||||
const currentStep: AppOnboardingSteps = useMemo(() => {
|
||||
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
|
||||
return AppOnboardingSteps.CONFIGURE_STEP;
|
||||
}
|
||||
return step;
|
||||
}, [step, configureStep]);
|
||||
const stepObj = STEPS_MAP[currentStep];
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const formPortalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formMethods = useForm<TEventTypesForm>({
|
||||
defaultValues: {
|
||||
eventTypes,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
eventTypes && formMethods.setValue("eventTypes", eventTypes);
|
||||
}, [eventTypes]);
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("event_type_updated_successfully", { eventTypeTitle: data.eventType?.title }), "success");
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
let message = "";
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`;
|
||||
}
|
||||
|
||||
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
|
||||
message = `${err.data.code}: ${t(err.message)}`;
|
||||
}
|
||||
|
||||
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
|
||||
message = t("unexpected_error_try_again");
|
||||
}
|
||||
|
||||
showToast(message ? t(message) : t(err.message), "error");
|
||||
},
|
||||
});
|
||||
|
||||
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(
|
||||
getAppOnboardingUrl({
|
||||
slug: appMetadata.slug,
|
||||
step: AppOnboardingSteps.EVENT_TYPES_STEP,
|
||||
teamId,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSelectingAccount(false);
|
||||
router.push(`/apps`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetUpLater = () => {
|
||||
router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pathname}
|
||||
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
|
||||
data-testid="onboarding">
|
||||
<Head>
|
||||
<title>
|
||||
{t("install")} {appMetadata?.name ?? ""}
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="mx-auto py-6 sm:px-4 md:py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]" ref={formPortalRef}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
id="outer-event-type-form"
|
||||
handleSubmit={async (values) => {
|
||||
const mutationPromises = values?.eventTypes
|
||||
.filter((eventType) => eventType.selected)
|
||||
.map((value: TEventType) => {
|
||||
// Prevent two payment apps to be enabled
|
||||
// Ok to cast type here because this metadata will be updated as the event type metadata
|
||||
if (
|
||||
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
|
||||
)
|
||||
throw new Error(t("event_setup_multiple_payment_apps_error"));
|
||||
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,
|
||||
});
|
||||
});
|
||||
try {
|
||||
await Promise.all(mutationPromises);
|
||||
router.push("/event-types");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}>
|
||||
<StepHeader
|
||||
title={stepObj.getTitle(appMetadata.name)}
|
||||
subtitle={stepObj.getDescription(appMetadata.name)}>
|
||||
<Steps maxSteps={MAX_NUMBER_OF_STEPS} currentStep={stepObj.stepNumber} disableNavigation />
|
||||
</StepHeader>
|
||||
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
|
||||
<AccountsStepCard
|
||||
teams={teams}
|
||||
personalAccount={personalAccount}
|
||||
onSelect={handleSelectAccount}
|
||||
loading={isSelectingAccount}
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
|
||||
eventTypes &&
|
||||
Boolean(eventTypes?.length) && (
|
||||
<EventTypesStepCard
|
||||
setConfigureStep={setConfigureStep}
|
||||
userName={userName}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
|
||||
<ConfigureStepCard
|
||||
slug={appMetadata.slug}
|
||||
categories={appMetadata.categories}
|
||||
credentialId={credentialId}
|
||||
userName={userName}
|
||||
loading={updateMutation.isPending}
|
||||
formPortalRef={formPortalRef}
|
||||
setConfigureStep={setConfigureStep}
|
||||
eventTypes={eventTypes}
|
||||
handleSetUpLater={handleSetUpLater}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Redirect Error map to give context on edge cases, this is for the devs, never shown to users
|
||||
const ERROR_MESSAGES = {
|
||||
appNotFound: "App not found",
|
||||
userNotAuthed: "User is not logged in",
|
||||
userNotFound: "User from session not found",
|
||||
appNotExtendsEventType: "App does not extend EventTypes",
|
||||
userNotInTeam: "User is not in provided team",
|
||||
} as const;
|
||||
|
||||
const getUser = async (userId: number) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
username: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(ERROR_MESSAGES.userNotFound);
|
||||
}
|
||||
return user;
|
||||
};
|
||||
|
||||
const getAppBySlug = async (appSlug: string) => {
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: appSlug, enabled: true },
|
||||
select: { slug: true, keys: true, enabled: true, dirName: true },
|
||||
});
|
||||
if (!app) throw new Error(ERROR_MESSAGES.appNotFound);
|
||||
return app;
|
||||
};
|
||||
|
||||
const getEventTypes = async (userId: number, teamId?: number) => {
|
||||
const eventTypes = (
|
||||
await prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
description: true,
|
||||
durationLimits: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
title: true,
|
||||
position: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
team: { select: { slug: true } },
|
||||
schedulingType: true,
|
||||
teamId: true,
|
||||
users: { select: { username: true } },
|
||||
seatsPerTimeSlot: true,
|
||||
slug: true,
|
||||
},
|
||||
where: teamId ? { teamId } : { userId, 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,
|
||||
}));
|
||||
};
|
||||
|
||||
const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => {
|
||||
const appInstalls = await prisma.credential.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
appId: appSlug,
|
||||
userId: userId,
|
||||
},
|
||||
teamIds && Boolean(teamIds.length)
|
||||
? {
|
||||
appId: appSlug,
|
||||
teamId: { in: teamIds },
|
||||
}
|
||||
: {},
|
||||
],
|
||||
},
|
||||
});
|
||||
return appInstalls;
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
try {
|
||||
let eventTypes: TEventType[] | null = null;
|
||||
const { req, res, query, params } = context;
|
||||
const stepsEnum = z.enum(STEPS);
|
||||
const parsedAppSlug = z.coerce.string().parse(query?.slug);
|
||||
const parsedStepParam = z.coerce.string().parse(params?.step);
|
||||
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
|
||||
const _ = stepsEnum.parse(parsedStepParam);
|
||||
const session = await getServerSession({ req, res });
|
||||
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";
|
||||
|
||||
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
|
||||
if (!hasEventTypes) {
|
||||
throw new Error(ERROR_MESSAGES.appNotExtendsEventType);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
const userAcceptedTeams = user.teams.map((team) => ({ ...team.team }));
|
||||
const hasTeams = Boolean(userAcceptedTeams.length);
|
||||
|
||||
const appInstalls = await getAppInstallsBySlug(
|
||||
parsedAppSlug,
|
||||
user.id,
|
||||
userAcceptedTeams.map(({ id }) => id)
|
||||
);
|
||||
|
||||
if (parsedTeamIdParam) {
|
||||
const isUserMemberOfTeam = userAcceptedTeams.some((team) => team.id === parsedTeamIdParam);
|
||||
if (!isUserMemberOfTeam) {
|
||||
throw new Error(ERROR_MESSAGES.userNotInTeam);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
|
||||
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
|
||||
if (eventTypes.length === 0) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const personalAccount = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
|
||||
};
|
||||
|
||||
const teamsWithIsAppInstalled = hasTeams
|
||||
? userAcceptedTeams.map((team) => ({
|
||||
...team,
|
||||
alreadyInstalled: appInstalls.some(
|
||||
(install) => Boolean(install.teamId) && install.teamId === team.id
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
let credentialId = null;
|
||||
if (parsedTeamIdParam) {
|
||||
credentialId =
|
||||
appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
|
||||
} else {
|
||||
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(locale, ["common"])),
|
||||
app,
|
||||
appMetadata,
|
||||
step: parsedStepParam,
|
||||
teams: teamsWithIsAppInstalled,
|
||||
personalAccount,
|
||||
eventTypes,
|
||||
teamId: parsedTeamIdParam ?? null,
|
||||
userName: user.username,
|
||||
credentialId,
|
||||
} as OnboardingPageProps,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
switch (err.message) {
|
||||
case ERROR_MESSAGES.userNotAuthed:
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
case ERROR_MESSAGES.userNotFound:
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
default:
|
||||
return { redirect: { permanent: false, destination: "/apps" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
OnboardingPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default OnboardingPage;
|
||||
@@ -249,7 +249,9 @@ inferSSRProps<typeof getServerSideProps> & WithNonceProps<{}>) {
|
||||
CustomStartIcon={<GoogleIcon />}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("google");
|
||||
await signIn("google", {
|
||||
callbackUrl,
|
||||
});
|
||||
}}>
|
||||
{t("signin_with_google")}
|
||||
</Button>
|
||||
|
||||
@@ -45,10 +45,10 @@ function VerifyEmailPage() {
|
||||
className="underline"
|
||||
loading={mutation.isPending}
|
||||
onClick={() => {
|
||||
showToast("Send email", "success");
|
||||
showToast(t("send_email"), "success");
|
||||
mutation.mutate();
|
||||
}}>
|
||||
Resend Email
|
||||
{t("resend_email")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useDeleteOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { HelpCards } from "@components/settings/platform/dashboard/HelpCards";
|
||||
import { ManagedUserList } from "@components/settings/platform/dashboard/managed-user-list";
|
||||
import { OAuthClientsList } from "@components/settings/platform/dashboard/oauth-clients-list";
|
||||
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
|
||||
@@ -68,6 +69,7 @@ export default function Platform() {
|
||||
withoutMain={false}
|
||||
subtitle="Manage everything related to platform."
|
||||
isPlatformUser={true}>
|
||||
<HelpCards />
|
||||
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
|
||||
<ManagedUserList
|
||||
oauthClients={data}
|
||||
|
||||
+19
-17
@@ -538,23 +538,25 @@ export default function Signup({
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-subtle ">
|
||||
<Trans i18nKey="signing_up_terms">
|
||||
By proceeding, you agree to our{" "}
|
||||
<Link
|
||||
className="text-emphasis hover:underline"
|
||||
href={`${WEBSITE_URL}/terms`}
|
||||
target="_blank">
|
||||
Terms
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link
|
||||
className="text-emphasis hover:underline"
|
||||
href={`${WEBSITE_URL}/privacy`}
|
||||
target="_blank">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
<Trans
|
||||
i18nKey="signing_up_terms"
|
||||
components={[
|
||||
<Link
|
||||
className="text-emphasis hover:underline"
|
||||
key="terms"
|
||||
href={`${WEBSITE_URL}/terms`}
|
||||
target="_blank">
|
||||
Terms
|
||||
</Link>,
|
||||
<Link
|
||||
className="text-emphasis hover:underline"
|
||||
key="privacy"
|
||||
href={`${WEBSITE_URL}/privacy`}
|
||||
target="_blank">
|
||||
Privacy Policy.
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -167,6 +168,7 @@ function TeamPage({
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
origin={getOrgFullOrigin(currentOrgDomain)}
|
||||
title={teamName}
|
||||
description={teamName}
|
||||
meeting={{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user