Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfacd8a780 | ||
|
|
d4bff9d6b1 | ||
|
|
6defa1cd7d | ||
|
|
5b2b5fd967 | ||
|
|
6932498d48 | ||
|
|
e07f800417 | ||
|
|
464e5f1496 | ||
|
|
724e3f2755 | ||
|
|
59c76141ce | ||
|
|
a494428334 | ||
|
|
7145a2d640 | ||
|
|
baaa04151d | ||
|
|
381125becd | ||
|
|
091327afee | ||
|
|
f1d7d7f02a | ||
|
|
84bd4d3d38 | ||
|
|
0367cf8e41 | ||
|
|
afea724e4d | ||
|
|
a4915184cd | ||
|
|
b7a7797293 | ||
|
|
d0e59f1824 | ||
|
|
67befea66e | ||
|
|
f18ebc4d67 | ||
|
|
5324832f13 | ||
|
|
2d9162a4dd | ||
|
|
d9443162ef | ||
|
|
e8dbab99eb | ||
|
|
cdcc04c8ff | ||
|
|
740a1c991e | ||
|
|
70cfe8d693 | ||
|
|
520e711ca6 | ||
|
|
6d1fe32a39 | ||
|
|
1cc548284d | ||
|
|
d49843de0b | ||
|
|
42efd95476 | ||
|
|
2ec68ef1aa | ||
|
|
6e09489685 | ||
|
|
af07c411ee | ||
|
|
a22ca4758c | ||
|
|
bb3ba88f18 | ||
|
|
ce646c590f | ||
|
|
ba8cca55e8 | ||
|
|
ae738fae35 | ||
|
|
2922c8fe77 | ||
|
|
4599740637 | ||
|
|
8d17ca536e | ||
|
|
3fa7b30485 | ||
|
|
d9adb6c1fc | ||
|
|
f4944a05c9 | ||
|
|
6ba49a73df | ||
|
|
babeb1f31d | ||
|
|
13cbb59e5e | ||
|
|
8d2a072645 | ||
|
|
b2244503b1 | ||
|
|
9d67e1c473 | ||
|
|
489d56dad1 | ||
|
|
532c2f57a1 | ||
|
|
efca90507b | ||
|
|
28ff809739 | ||
|
|
8e73421094 | ||
|
|
5bab2af500 | ||
|
|
9b7c6585d8 | ||
|
|
58930c4c5e | ||
|
|
9412226a76 | ||
|
|
df12581879 | ||
|
|
0ec1638ca7 | ||
|
|
2516e18ebb | ||
|
|
d2f8076c70 | ||
|
|
cbfb11015e | ||
|
|
2d90d11e9e | ||
|
|
7ae0d6b7cc | ||
|
|
e0dc2ba03a |
+14
-1
@@ -383,10 +383,23 @@ TASKER_ENABLE_EMAILS=0
|
|||||||
# Ratelimiting via unkey
|
# Ratelimiting via unkey
|
||||||
UNKEY_ROOT_KEY=
|
UNKEY_ROOT_KEY=
|
||||||
|
|
||||||
# Used for Cal.ai Enterprise Voice AI Agents
|
# Used for Cal.ai Voice AI Agents
|
||||||
# https://retellai.com
|
# https://retellai.com
|
||||||
RETELL_AI_KEY=
|
RETELL_AI_KEY=
|
||||||
|
|
||||||
|
# Used for buying phone number for cal ai voice agent
|
||||||
|
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=
|
||||||
|
|
||||||
|
|
||||||
|
CAL_AI_CALL_RATE_PER_MINUTE=0.29
|
||||||
|
|
||||||
|
|
||||||
|
STRIPE_WEBHOOK_SECRET_BILLING=
|
||||||
|
|
||||||
|
|
||||||
|
# Price for buying a phone number for cal.ai voice agent (Default is 5)
|
||||||
|
NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE=
|
||||||
|
|
||||||
# Used for the huddle01 integration
|
# Used for the huddle01 integration
|
||||||
HUDDLE01_API_TOKEN=
|
HUDDLE01_API_TOKEN=
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -2,7 +2,12 @@
|
|||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/lib/**/* @calcom/Foundation
|
/apps/web/lib/csp.ts @calcom/Foundation
|
||||||
|
/apps/web/lib/buildNonce.ts @calcom/Foundation
|
||||||
|
/apps/web/lib/daily-webhook/**/* @calcom/Foundation
|
||||||
|
/apps/web/lib/core/**/* @calcom/Foundation
|
||||||
|
/apps/web/lib/booking.ts @calcom/Foundation
|
||||||
|
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
||||||
/apps/web/middleware.ts @calcom/Foundation
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
/infra/**/* @calcom/Foundation
|
/infra/**/* @calcom/Foundation
|
||||||
|
|||||||
@@ -74,8 +74,14 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e-atoms:
|
||||||
|
name: Tests
|
||||||
|
needs: [lint, build, build-atoms, build-api-v2]
|
||||||
|
uses: ./.github/workflows/e2e-atoms.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
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]
|
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
name: Atoms E2E Tests
|
name: Atoms E2E Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
workflow_call:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'packages/platform/atoms/**'
|
|
||||||
- 'packages/platform/examples/base/**'
|
|
||||||
- 'apps/api/v2/**'
|
|
||||||
- '.github/workflows/atoms-e2e.yml'
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: write
|
actions: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -25,7 +17,7 @@ env:
|
|||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
atoms-e2e:
|
e2e-atoms:
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
name: Atoms E2E Tests
|
name: Atoms E2E Tests
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
@@ -45,13 +37,13 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: atoms-e2e-test-results
|
name: e2e-atoms-test-results
|
||||||
path: test-results
|
path: test-results
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: Upload Playwright Report
|
- name: Upload Playwright Report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: atoms-e2e-playwright-report
|
name: e2e-atoms-playwright-report
|
||||||
path: playwright-report
|
path: playwright-report
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier"
|
||||||
apply-labels-from-issue:
|
apply-labels-from-issue:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Performance Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [created]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
base_url:
|
||||||
|
description: 'Base URL for performance tests'
|
||||||
|
required: false
|
||||||
|
default: 'https://cal.dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
performance-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ github.event.inputs.base_url || 'https://cal.dev' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup K6
|
||||||
|
uses: grafana/setup-k6-action@v1
|
||||||
|
with:
|
||||||
|
k6-version: '1.0.0'
|
||||||
|
|
||||||
|
- name: Run Smoke Tests
|
||||||
|
uses: grafana/run-k6-action@v1
|
||||||
|
env:
|
||||||
|
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
|
||||||
|
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
|
||||||
|
with:
|
||||||
|
path: tests/performance/smoke/*.js
|
||||||
|
cloud-run-locally: true
|
||||||
|
fail-fast: true
|
||||||
|
|
||||||
|
- name: Run Load Tests
|
||||||
|
uses: grafana/run-k6-action@v1
|
||||||
|
env:
|
||||||
|
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
|
||||||
|
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
|
||||||
|
with:
|
||||||
|
path: tests/performance/load/*.js
|
||||||
|
cloud-run-locally: true
|
||||||
|
fail-fast: true
|
||||||
|
|
||||||
|
- name: Run Stress Tests
|
||||||
|
uses: grafana/run-k6-action@v1
|
||||||
|
env:
|
||||||
|
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
|
||||||
|
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
|
||||||
|
with:
|
||||||
|
path: tests/performance/stress/*.js
|
||||||
|
cloud-run-locally: true
|
||||||
|
fail-fast: true
|
||||||
|
|
||||||
|
- name: Run Spike Tests
|
||||||
|
uses: grafana/run-k6-action@v1
|
||||||
|
env:
|
||||||
|
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
|
||||||
|
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
|
||||||
|
with:
|
||||||
|
path: tests/performance/spike/*.js
|
||||||
|
cloud-run-locally: true
|
||||||
|
fail-fast: true
|
||||||
@@ -195,6 +195,13 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e-atoms:
|
||||||
|
name: Tests
|
||||||
|
needs: [changes, check-label, build, build-atoms, build-api-v2]
|
||||||
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
|
uses: ./.github/workflows/e2e-atoms.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze Build
|
name: Analyze Build
|
||||||
needs: [build]
|
needs: [build]
|
||||||
@@ -204,7 +211,7 @@ jobs:
|
|||||||
merge-reports:
|
merge-reports:
|
||||||
name: Merge reports
|
name: Merge reports
|
||||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
||||||
uses: ./.github/workflows/merge-reports.yml
|
uses: ./.github/workflows/merge-reports.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
@@ -249,6 +256,7 @@ jobs:
|
|||||||
e2e-embed,
|
e2e-embed,
|
||||||
e2e-embed-react,
|
e2e-embed-react,
|
||||||
e2e-app-store,
|
e2e-app-store,
|
||||||
|
e2e-atoms,
|
||||||
]
|
]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
|||||||
@@ -97,3 +97,5 @@ apps/auth
|
|||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
i18n.cache
|
i18n.cache
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.335",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
|||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -53,6 +54,7 @@ import { Module } from "@nestjs/common";
|
|||||||
StripeModule,
|
StripeModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
TeamsEventTypesModule,
|
TeamsEventTypesModule,
|
||||||
|
MembershipsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.ser
|
|||||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import {
|
||||||
|
AuthOptionalUser,
|
||||||
|
GetOptionalUser,
|
||||||
|
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
@@ -96,6 +100,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
|
@UseGuards(OptionalApiAuthGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Create a booking",
|
summary: "Create a booking",
|
||||||
description: `
|
description: `
|
||||||
@@ -138,9 +143,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
async createBooking(
|
async createBooking(
|
||||||
@Body(new CreateBookingInputPipe())
|
@Body(new CreateBookingInputPipe())
|
||||||
body: CreateBookingInput,
|
body: CreateBookingInput,
|
||||||
@Req() request: Request
|
@Req() request: Request,
|
||||||
|
@GetOptionalUser() user: AuthOptionalUser
|
||||||
): Promise<CreateBookingOutput_2024_08_13> {
|
): Promise<CreateBookingOutput_2024_08_13> {
|
||||||
const booking = await this.bookingsService.createBooking(request, body);
|
const booking = await this.bookingsService.createBooking(request, body, user);
|
||||||
|
|
||||||
if (Array.isArray(booking)) {
|
if (Array.isArray(booking)) {
|
||||||
await this.bookingsService.billBookings(booking);
|
await this.bookingsService.billBookings(booking);
|
||||||
|
|||||||
+270
-724
File diff suppressed because it is too large
Load Diff
+73
-1
@@ -3,6 +3,7 @@ import { AppModule } from "@/app.module";
|
|||||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||||
import { Locales } from "@/lib/enums/locales";
|
import { Locales } from "@/lib/enums/locales";
|
||||||
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import {
|
import {
|
||||||
CreateManagedUserData,
|
CreateManagedUserData,
|
||||||
CreateManagedUserOutput,
|
CreateManagedUserOutput,
|
||||||
@@ -80,7 +81,7 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||||
imports: [AppModule, UsersModule],
|
imports: [AppModule, UsersModule, MembershipsModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -772,6 +773,77 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("event type booking requires authentication", () => {
|
||||||
|
let eventTypeRequiringAuthenticationId: number;
|
||||||
|
|
||||||
|
let body: CreateBookingInput_2024_08_13;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `event-type-requiring-authentication-${randomString()}`,
|
||||||
|
slug: `event-type-requiring-authentication-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
bookingRequiresAuthentication: true,
|
||||||
|
},
|
||||||
|
secondManagedUser.user.id
|
||||||
|
);
|
||||||
|
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
|
||||||
|
|
||||||
|
body = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||||
|
attendee: {
|
||||||
|
email: "external@example.com",
|
||||||
|
name: "External Attendee",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can't be booked without credentials", async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can't be booked with managed user credentials who is not admin and not event type owner", async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be booked with managed user credentials who is event type owner", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${secondManagedUser.accessToken}`)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const bookingId = response.body.data.id;
|
||||||
|
await bookingsRepositoryFixture.deleteById(bookingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be booked with managed user credentials who is admin", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const bookingId = response.body.data.id;
|
||||||
|
await bookingsRepositoryFixture.deleteById(bookingId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
|
|||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
import { getPagination } from "@/lib/pagination/pagination";
|
import { getPagination } from "@/lib/pagination/pagination";
|
||||||
|
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
@@ -18,7 +21,14 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve
|
|||||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
@@ -95,10 +105,12 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly organizationsRepository: OrganizationsRepository,
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
private readonly teamsRepository: TeamsRepository,
|
private readonly teamsRepository: TeamsRepository,
|
||||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||||
|
private readonly membershipsRepository: MembershipsRepository,
|
||||||
|
private readonly membershipsService: MembershipsService,
|
||||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createBooking(request: Request, body: CreateBookingInput) {
|
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||||
let bookingTeamEventType = false;
|
let bookingTeamEventType = false;
|
||||||
try {
|
try {
|
||||||
const eventType = await this.getBookedEventType(body);
|
const eventType = await this.getBookedEventType(body);
|
||||||
@@ -108,6 +120,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||||
}
|
}
|
||||||
|
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser);
|
||||||
|
|
||||||
if (eventType.schedulingType === "MANAGED") {
|
if (eventType.schedulingType === "MANAGED") {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -142,6 +155,56 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkBookingRequiresAuthenticationSetting(
|
||||||
|
eventType: EventTypeWithOwnerAndTeam,
|
||||||
|
authUser: AuthOptionalUser
|
||||||
|
) {
|
||||||
|
if (!eventType.bookingRequiresAuthentication) return true;
|
||||||
|
if (!authUser) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"checkBookingRequiresAuthentication - request must be authenticated by passing credentials belonging to event type owner, host or team or org admin or owner."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUserId = authUser.id;
|
||||||
|
const authUserRole = authUser.role;
|
||||||
|
const eventTypeId = eventType.id;
|
||||||
|
const teamId = eventType.teamId;
|
||||||
|
const bookingUserId = eventType.owner?.id || null;
|
||||||
|
|
||||||
|
if (authUserRole === "ADMIN") return;
|
||||||
|
|
||||||
|
if (bookingUserId === authUserId) return;
|
||||||
|
|
||||||
|
if (eventTypeId) {
|
||||||
|
const [isUserHost, isUserAssigned] = await Promise.all([
|
||||||
|
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
|
||||||
|
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isUserHost || isUserAssigned) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamId) {
|
||||||
|
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(
|
||||||
|
authUserId,
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
if (membership) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
bookingUserId &&
|
||||||
|
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, bookingUserId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getBookedEventType(body: CreateBookingInput) {
|
async getBookedEventType(body: CreateBookingInput) {
|
||||||
if (body.eventTypeId) {
|
if (body.eventTypeId) {
|
||||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||||
|
|||||||
+5
-1
@@ -498,6 +498,7 @@ describe("Event types Endpoints", () => {
|
|||||||
lightThemeHex: "#fafafa",
|
lightThemeHex: "#fafafa",
|
||||||
},
|
},
|
||||||
customName: `{Event type title} between {Organiser} and {Scheduler}`,
|
customName: `{Event type title} between {Organiser} and {Scheduler}`,
|
||||||
|
bookingRequiresAuthentication: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
@@ -570,6 +571,7 @@ describe("Event types Endpoints", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
|
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
|
||||||
|
expect(createdEventType.bookingRequiresAuthentication).toEqual(true);
|
||||||
eventType = responseBody.data;
|
eventType = responseBody.data;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1026,6 +1028,7 @@ describe("Event types Endpoints", () => {
|
|||||||
lightThemeHex: "#fafafa",
|
lightThemeHex: "#fafafa",
|
||||||
},
|
},
|
||||||
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
|
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
|
||||||
|
bookingRequiresAuthentication: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
@@ -1120,6 +1123,8 @@ describe("Event types Endpoints", () => {
|
|||||||
eventType.color = updatedEventType.color;
|
eventType.color = updatedEventType.color;
|
||||||
eventType.bookingFields = updatedEventType.bookingFields;
|
eventType.bookingFields = updatedEventType.bookingFields;
|
||||||
eventType.calVideoSettings = updatedEventType.calVideoSettings;
|
eventType.calVideoSettings = updatedEventType.calVideoSettings;
|
||||||
|
|
||||||
|
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2028,7 +2033,6 @@ describe("Event types Endpoints", () => {
|
|||||||
const username = name;
|
const username = name;
|
||||||
let user: User;
|
let user: User;
|
||||||
let legacyEventTypeId1: number;
|
let legacyEventTypeId1: number;
|
||||||
let legacyEventTypeId2: number;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
|
|||||||
@@ -132,4 +132,26 @@ export class EventTypesRepository_2024_06_14 {
|
|||||||
async deleteEventType(eventTypeId: number) {
|
async deleteEventType(eventTypeId: number) {
|
||||||
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isUserHostOfEventType(userId: number, eventTypeId: number) {
|
||||||
|
const eventType = await this.dbRead.prisma.eventType.findFirst({
|
||||||
|
where: {
|
||||||
|
id: eventTypeId,
|
||||||
|
hosts: { some: { userId: userId } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return !!eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isUserAssignedToEventType(userId: number, eventTypeId: number) {
|
||||||
|
const eventType = await this.dbRead.prisma.eventType.findFirst({
|
||||||
|
where: {
|
||||||
|
id: eventTypeId,
|
||||||
|
users: { some: { id: userId } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return !!eventType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -90,6 +90,7 @@ type Input = Pick<
|
|||||||
| "hideOrganizerEmail"
|
| "hideOrganizerEmail"
|
||||||
| "calVideoSettings"
|
| "calVideoSettings"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
|
| "bookingRequiresAuthentication"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -129,6 +130,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
hideOrganizerEmail,
|
hideOrganizerEmail,
|
||||||
calVideoSettings,
|
calVideoSettings,
|
||||||
hidden,
|
hidden,
|
||||||
|
bookingRequiresAuthentication,
|
||||||
} = databaseEventType;
|
} = databaseEventType;
|
||||||
|
|
||||||
const locations = this.transformLocations(databaseEventType.locations);
|
const locations = this.transformLocations(databaseEventType.locations);
|
||||||
@@ -206,6 +208,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
hideOrganizerEmail,
|
hideOrganizerEmail,
|
||||||
calVideoSettings,
|
calVideoSettings,
|
||||||
hidden,
|
hidden,
|
||||||
|
bookingRequiresAuthentication,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -520,6 +520,8 @@ export const systemBeforeFieldName: NameSystemField = {
|
|||||||
name: "fullName",
|
name: "fullName",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
|
label: "",
|
||||||
|
placeholder: "",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -292,8 +292,8 @@ describe("transformBookingFieldsInternalToApi", () => {
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
required: true,
|
required: true,
|
||||||
disableOnPrefill: false,
|
disableOnPrefill: false,
|
||||||
label: undefined,
|
label: "",
|
||||||
placeholder: undefined,
|
placeholder: "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { extractUserContext } from "@/lib/extract-user-context";
|
|||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||||
import {
|
import { Prisma } from "@prisma/client";
|
||||||
PrismaClientInitializationError,
|
|
||||||
PrismaClientKnownRequestError,
|
|
||||||
PrismaClientRustPanicError,
|
|
||||||
PrismaClientUnknownRequestError,
|
|
||||||
PrismaClientValidationError,
|
|
||||||
} from "@prisma/client/runtime/library";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -21,18 +15,18 @@ import {
|
|||||||
import { Response } from "@calcom/platform-types";
|
import { Response } from "@calcom/platform-types";
|
||||||
|
|
||||||
type PrismaError =
|
type PrismaError =
|
||||||
| PrismaClientInitializationError
|
| Prisma.PrismaClientInitializationError
|
||||||
| PrismaClientKnownRequestError
|
| Prisma.PrismaClientKnownRequestError
|
||||||
| PrismaClientRustPanicError
|
| Prisma.PrismaClientRustPanicError
|
||||||
| PrismaClientUnknownRequestError
|
| Prisma.PrismaClientUnknownRequestError
|
||||||
| PrismaClientValidationError;
|
| Prisma.PrismaClientValidationError;
|
||||||
|
|
||||||
@Catch(
|
@Catch(
|
||||||
PrismaClientInitializationError,
|
Prisma.PrismaClientInitializationError,
|
||||||
PrismaClientKnownRequestError,
|
Prisma.PrismaClientKnownRequestError,
|
||||||
PrismaClientRustPanicError,
|
Prisma.PrismaClientRustPanicError,
|
||||||
PrismaClientUnknownRequestError,
|
Prisma.PrismaClientUnknownRequestError,
|
||||||
PrismaClientValidationError
|
Prisma.PrismaClientValidationError
|
||||||
)
|
)
|
||||||
export class PrismaExceptionFilter implements ExceptionFilter {
|
export class PrismaExceptionFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger("PrismaExceptionFilter");
|
private readonly logger = new Logger("PrismaExceptionFilter");
|
||||||
@@ -58,7 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
|||||||
let message = "There was an error, please try again later.";
|
let message = "There was an error, please try again later.";
|
||||||
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let errorCode = INTERNAL_SERVER_ERROR;
|
let errorCode = INTERNAL_SERVER_ERROR;
|
||||||
if (error instanceof PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
case "P2002": // Unique constraint failed
|
case "P2002": // Unique constraint failed
|
||||||
errorCode = CONFLICT;
|
errorCode = CONFLICT;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaAttributeRepository as PrismaAttributeRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaAttributeRepository extends PrismaAttributeRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaHostRepository as PrismaHostRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaHostRepository extends PrismaHostRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
|
||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { LuckyUserService as BaseLuckyUserService } from "@calcom/platform-libraries/bookings";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LuckyUserService extends BaseLuckyUserService {
|
||||||
|
constructor(
|
||||||
|
bookingRepository: PrismaBookingRepository,
|
||||||
|
hostRepository: PrismaHostRepository,
|
||||||
|
oooRepository: PrismaOOORepository,
|
||||||
|
userRepository: PrismaUserRepository,
|
||||||
|
attributeRepository: PrismaAttributeRepository
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
bookingRepository: bookingRepository,
|
||||||
|
hostRepository: hostRepository,
|
||||||
|
oooRepository: oooRepository,
|
||||||
|
userRepository: userRepository,
|
||||||
|
attributeRepository: attributeRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -2,6 +2,8 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st
|
|||||||
import { ExecutionContext } from "@nestjs/common";
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
import { createParamDecorator } from "@nestjs/common";
|
import { createParamDecorator } from "@nestjs/common";
|
||||||
|
|
||||||
|
export type AuthOptionalUser = ApiAuthGuardUser | null;
|
||||||
|
|
||||||
export const GetOptionalUser = createParamDecorator<
|
export const GetOptionalUser = createParamDecorator<
|
||||||
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
|
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
|
||||||
ExecutionContext
|
ExecutionContext
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
teamId: teamId.toString(),
|
teamId: teamId.toString(),
|
||||||
plan: plan.toString(),
|
plan: plan.toString(),
|
||||||
},
|
},
|
||||||
currency: "usd",
|
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
metadata: {
|
metadata: {
|
||||||
teamId: teamId.toString(),
|
teamId: teamId.toString(),
|
||||||
@@ -117,7 +116,6 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
teamId: teamId.toString(),
|
teamId: teamId.toString(),
|
||||||
plan: plan.toString(),
|
plan: plan.toString(),
|
||||||
},
|
},
|
||||||
currency: "usd",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||||
@@ -403,7 +401,6 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
fromReschedule?: string | null;
|
fromReschedule?: string | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (this.configService.get("e2e")) {
|
if (this.configService.get("e2e")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -465,7 +462,6 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await this.billingQueue.close();
|
await this.billingQueue.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { MembershipRole } from "@calcom/prisma/client";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MembershipsRepository {
|
export class MembershipsRepository {
|
||||||
@@ -90,6 +90,26 @@ export class MembershipsRepository {
|
|||||||
return !!adminMembership;
|
return !!adminMembership;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrgIdsWhereUserIsAdminOrOwner(userId: number) {
|
||||||
|
const userOrgAdminMemberships = await this.dbRead.prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
accepted: true,
|
||||||
|
role: {
|
||||||
|
in: ["OWNER", "ADMIN"],
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return userOrgAdminMemberships.map((m) => m.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||||
const membership = await this.dbRead.prisma.membership.create({
|
const membership = await this.dbRead.prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -103,4 +123,46 @@ export class MembershipsRepository {
|
|||||||
|
|
||||||
return membership;
|
return membership;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserAdminOrOwnerTeamMembership(userId: number, teamId: number) {
|
||||||
|
return this.dbRead.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
teamId: teamId,
|
||||||
|
accepted: true,
|
||||||
|
role: {
|
||||||
|
in: ["OWNER", "ADMIN"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserMembershipInOneOfOrgs(userId: number, orgIds: number[]) {
|
||||||
|
return this.dbRead.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
accepted: true,
|
||||||
|
teamId: {
|
||||||
|
in: orgIds,
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserMembershipInOneOfOrgsTeams(userId: number, orgIds: number[]) {
|
||||||
|
return this.dbRead.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
accepted: true,
|
||||||
|
team: {
|
||||||
|
parentId: {
|
||||||
|
in: orgIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,28 @@ export class MembershipsService {
|
|||||||
"teamId"
|
"teamId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isUserOrgAdminOrOwnerOfAnotherUser(userId: number, anotherUserId: number) {
|
||||||
|
const orgIdsWhereUserIsAdminOrOwner = await this.membershipsRepository.getOrgIdsWhereUserIsAdminOrOwner(
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgIdsWhereUserIsAdminOrOwner.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anotherUserOrgMembership = await this.membershipsRepository.getUserMembershipInOneOfOrgs(
|
||||||
|
anotherUserId,
|
||||||
|
orgIdsWhereUserIsAdminOrOwner
|
||||||
|
);
|
||||||
|
|
||||||
|
if (anotherUserOrgMembership) return true;
|
||||||
|
|
||||||
|
const anotherUserOrgTeamMembership = await this.membershipsRepository.getUserMembershipInOneOfOrgsTeams(
|
||||||
|
anotherUserId,
|
||||||
|
orgIdsWhereUserIsAdminOrOwner
|
||||||
|
);
|
||||||
|
|
||||||
|
return !!anotherUserOrgTeamMembership;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -725,6 +725,8 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
type: "name",
|
type: "name",
|
||||||
slug: "name",
|
slug: "name",
|
||||||
|
label: "",
|
||||||
|
placeholder: "",
|
||||||
required: true,
|
required: true,
|
||||||
disableOnPrefill: false,
|
disableOnPrefill: false,
|
||||||
},
|
},
|
||||||
@@ -821,6 +823,8 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
type: "name",
|
type: "name",
|
||||||
slug: "name",
|
slug: "name",
|
||||||
|
label: "",
|
||||||
|
placeholder: "",
|
||||||
required: true,
|
required: true,
|
||||||
disableOnPrefill: false,
|
disableOnPrefill: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type Input = Pick<
|
|||||||
| "team"
|
| "team"
|
||||||
| "calVideoSettings"
|
| "calVideoSettings"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
|
| "bookingRequiresAuthentication"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
+5
@@ -43,6 +43,8 @@ export class OrganizationsTeamsSchedulesController {
|
|||||||
@DocsTags("Orgs / Teams / Schedules")
|
@DocsTags("Orgs / Teams / Schedules")
|
||||||
@ApiOperation({ summary: "Get all team member schedules" })
|
@ApiOperation({ summary: "Get all team member schedules" })
|
||||||
async getTeamSchedules(
|
async getTeamSchedules(
|
||||||
|
// note(Lauirs): putting orgId so swagger is generacted correctly
|
||||||
|
@Param("orgId", ParseIntPipe) _orgId: number,
|
||||||
@Param("teamId", ParseIntPipe) teamId: number,
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
@Query() queryParams: SkipTakePagination
|
@Query() queryParams: SkipTakePagination
|
||||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||||
@@ -65,6 +67,9 @@ export class OrganizationsTeamsSchedulesController {
|
|||||||
summary: "Get schedules of a team member",
|
summary: "Get schedules of a team member",
|
||||||
})
|
})
|
||||||
async getUserSchedules(
|
async getUserSchedules(
|
||||||
|
// note(Lauirs): putting orgId and teamId so swagger is generacted correctly
|
||||||
|
@Param("orgId", ParseIntPipe) _orgId: number,
|
||||||
|
@Param("teamId", ParseIntPipe) _teamId: number,
|
||||||
@Param("userId", ParseIntPipe) userId: number
|
@Param("userId", ParseIntPipe) userId: number
|
||||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||||
const schedules = await this.schedulesService.getUserSchedules(userId);
|
const schedules = await this.schedulesService.getUserSchedules(userId);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { VERSION_2024_09_04 } from "@/lib/api-versions";
|
import { VERSION_2024_09_04 } from "@/lib/api-versions";
|
||||||
import { OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER } from "@/lib/docs/headers";
|
import { OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER } from "@/lib/docs/headers";
|
||||||
import { GetOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
import {
|
||||||
|
AuthOptionalUser,
|
||||||
|
GetOptionalUser,
|
||||||
|
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||||
import { GetReservedSlotOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-reserved-slot.output";
|
import { GetReservedSlotOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-reserved-slot.output";
|
||||||
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
||||||
@@ -26,7 +29,6 @@ import {
|
|||||||
ApiResponse as DocsResponse,
|
ApiResponse as DocsResponse,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
@@ -261,7 +263,7 @@ export class SlotsController_2024_09_04 {
|
|||||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
async reserveSlot(
|
async reserveSlot(
|
||||||
@Body() body: ReserveSlotInput_2024_09_04,
|
@Body() body: ReserveSlotInput_2024_09_04,
|
||||||
@GetOptionalUser() user: User
|
@GetOptionalUser() user: AuthOptionalUser
|
||||||
): Promise<ReserveSlotOutputResponse_2024_09_04> {
|
): Promise<ReserveSlotOutputResponse_2024_09_04> {
|
||||||
const reservedSlot = await this.slotsService.reserveSlot(body, user?.id);
|
const reservedSlot = await this.slotsService.reserveSlot(body, user?.id);
|
||||||
|
|
||||||
|
|||||||
@@ -5547,6 +5547,97 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/organizations/{orgId}/teams/{teamId}/schedules": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsTeamsSchedulesController_getTeamSchedules",
|
||||||
|
"summary": "Get all team member schedules",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-cal-secret-key",
|
||||||
|
"in": "header",
|
||||||
|
"description": "For platform customers - OAuth client secret key",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-cal-client-id",
|
||||||
|
"in": "header",
|
||||||
|
"description": "For platform customers - OAuth client ID",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "take",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Maximum number of items to return",
|
||||||
|
"example": 25,
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 250,
|
||||||
|
"default": 250,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skip",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of items to skip",
|
||||||
|
"example": 0,
|
||||||
|
"schema": {
|
||||||
|
"minimum": 0,
|
||||||
|
"default": 0,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Orgs / Teams / Schedules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
||||||
@@ -5720,6 +5811,22 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "userId",
|
"name": "userId",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -13215,6 +13322,71 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/teams/{teamId}/schedules": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "TeamsSchedulesController_getTeamSchedules",
|
||||||
|
"summary": "Get all team member schedules",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "take",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Maximum number of items to return",
|
||||||
|
"example": 25,
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 250,
|
||||||
|
"default": 250,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skip",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of items to skip",
|
||||||
|
"example": 0,
|
||||||
|
"schema": {
|
||||||
|
"minimum": 0,
|
||||||
|
"default": 0,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Teams / Schedules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/teams/{teamId}/verified-resources/emails/verification-code/request": {
|
"/v2/teams/{teamId}/verified-resources/emails/verification-code/request": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "TeamsVerifiedResourcesController_requestEmailVerificationCode",
|
"operationId": "TeamsVerifiedResourcesController_requestEmailVerificationCode",
|
||||||
@@ -16504,6 +16676,14 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"bookingRequiresAuthentication": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
|
||||||
|
},
|
||||||
"locations": {
|
"locations": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||||
@@ -18166,6 +18346,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"bookingRequiresAuthentication": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
|
||||||
|
},
|
||||||
"ownerId": {
|
"ownerId": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"example": 10
|
"example": 10
|
||||||
@@ -18195,6 +18382,8 @@
|
|||||||
"successRedirectUrl",
|
"successRedirectUrl",
|
||||||
"isInstantEvent",
|
"isInstantEvent",
|
||||||
"scheduleId",
|
"scheduleId",
|
||||||
|
"hidden",
|
||||||
|
"bookingRequiresAuthentication",
|
||||||
"ownerId",
|
"ownerId",
|
||||||
"users"
|
"users"
|
||||||
]
|
]
|
||||||
@@ -18515,6 +18704,14 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"bookingRequiresAuthentication": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
|
||||||
|
},
|
||||||
"locations": {
|
"locations": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||||
@@ -20343,6 +20540,14 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"bookingRequiresAuthentication": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
|
||||||
|
},
|
||||||
"schedulingType": {
|
"schedulingType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -20777,6 +20982,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"bookingRequiresAuthentication": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
|
||||||
|
},
|
||||||
"teamId": {
|
"teamId": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
@@ -21223,6 +21435,14 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"bookingRequiresAuthentication": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
|
||||||
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -22589,6 +22809,11 @@
|
|||||||
},
|
},
|
||||||
"unit": {
|
"unit": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"enum": [
|
||||||
|
"hour",
|
||||||
|
"minute",
|
||||||
|
"day"
|
||||||
|
],
|
||||||
"description": "Unit for the offset time",
|
"description": "Unit for the offset time",
|
||||||
"example": "hour"
|
"example": "hour"
|
||||||
}
|
}
|
||||||
@@ -22612,6 +22837,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "beforeEvent",
|
"default": "beforeEvent",
|
||||||
|
"enum": [
|
||||||
|
"beforeEvent"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow",
|
"description": "Trigger type for the workflow",
|
||||||
"example": "beforeEvent"
|
"example": "beforeEvent"
|
||||||
}
|
}
|
||||||
@@ -22635,6 +22863,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "afterEvent",
|
"default": "afterEvent",
|
||||||
|
"enum": [
|
||||||
|
"afterEvent"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow",
|
"description": "Trigger type for the workflow",
|
||||||
"example": "afterEvent"
|
"example": "afterEvent"
|
||||||
}
|
}
|
||||||
@@ -22650,6 +22881,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "eventCancelled",
|
"default": "eventCancelled",
|
||||||
|
"enum": [
|
||||||
|
"eventCancelled"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow"
|
"description": "Trigger type for the workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -22663,6 +22897,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "newEvent",
|
"default": "newEvent",
|
||||||
|
"enum": [
|
||||||
|
"newEvent"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow"
|
"description": "Trigger type for the workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -22676,6 +22913,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "rescheduleEvent",
|
"default": "rescheduleEvent",
|
||||||
|
"enum": [
|
||||||
|
"rescheduleEvent"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow"
|
"description": "Trigger type for the workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -22697,6 +22937,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "afterGuestsCalVideoNoShow",
|
"default": "afterGuestsCalVideoNoShow",
|
||||||
|
"enum": [
|
||||||
|
"afterGuestsCalVideoNoShow"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow",
|
"description": "Trigger type for the workflow",
|
||||||
"example": "afterGuestsCalVideoNoShow"
|
"example": "afterGuestsCalVideoNoShow"
|
||||||
}
|
}
|
||||||
@@ -22720,6 +22963,9 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "afterHostsCalVideoNoShow",
|
"default": "afterHostsCalVideoNoShow",
|
||||||
|
"enum": [
|
||||||
|
"afterHostsCalVideoNoShow"
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow",
|
"description": "Trigger type for the workflow",
|
||||||
"example": "afterHostsCalVideoNoShow"
|
"example": "afterHostsCalVideoNoShow"
|
||||||
}
|
}
|
||||||
@@ -23314,6 +23560,17 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"enum": [
|
||||||
|
[
|
||||||
|
"beforeEvent",
|
||||||
|
"eventCancelled",
|
||||||
|
"newEvent",
|
||||||
|
"afterEvent",
|
||||||
|
"rescheduleEvent",
|
||||||
|
"afterHostsCalVideoNoShow",
|
||||||
|
"afterGuestsCalVideoNoShow"
|
||||||
|
]
|
||||||
|
],
|
||||||
"description": "Trigger type for the workflow"
|
"description": "Trigger type for the workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export class ApiKeysRepositoryFixture {
|
|||||||
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
||||||
const keyString = randomBytes(16).toString("hex");
|
const keyString = randomBytes(16).toString("hex");
|
||||||
const hashedKey = createHash("sha256").update(keyString).digest("hex");
|
const hashedKey = createHash("sha256").update(keyString).digest("hex");
|
||||||
console.log("createApiKey Fixutre", { hashedKey, keyString });
|
|
||||||
const apiKey = await this.prismaWriteClient.apiKey.create({
|
const apiKey = await this.prismaWriteClient.apiKey.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
|
||||||
|
|
||||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
|
||||||
import { Button } from "@calcom/ui/components/button";
|
|
||||||
|
|
||||||
export const generateMetadata = () =>
|
|
||||||
_generateMetadata(
|
|
||||||
(t) => `${t("access_denied")} | ${APP_NAME}`,
|
|
||||||
() => "",
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"/403"
|
|
||||||
);
|
|
||||||
|
|
||||||
async function Error403() {
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-subtle flex h-screen">
|
|
||||||
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
|
||||||
<h1 className="font-cal text-emphasis text-6xl">403</h1>
|
|
||||||
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("dont_have_access_this_page")}</h2>
|
|
||||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
|
|
||||||
{t("you_need_admin_or_owner_privileges_to_access")}
|
|
||||||
</p>
|
|
||||||
<Button href={WEBAPP_URL}>{t("go_back_home")}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error403;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { Button } from "@calcom/ui/components/button";
|
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
|
||||||
|
|
||||||
export default function CopyButton({ error }: { error: string }) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
className="mt-2 border-0 font-sans font-normal hover:bg-gray-300"
|
|
||||||
StartIcon="copy"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(error);
|
|
||||||
showToast("Link copied!", "success");
|
|
||||||
}}>
|
|
||||||
{t("copy")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
|
||||||
|
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
|
||||||
import { Button } from "@calcom/ui/components/button";
|
|
||||||
|
|
||||||
import CopyButton from "./copy-button";
|
|
||||||
|
|
||||||
export const generateMetadata = () =>
|
|
||||||
_generateMetadata(
|
|
||||||
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
|
|
||||||
() => "",
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"/500"
|
|
||||||
);
|
|
||||||
|
|
||||||
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-subtle flex h-screen">
|
|
||||||
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
|
||||||
<h1 className="font-cal text-emphasis text-6xl">500</h1>
|
|
||||||
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("500_error_message")}</h2>
|
|
||||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
|
|
||||||
{searchParams?.error && (
|
|
||||||
<div className="mb-8 flex flex-col">
|
|
||||||
<p className="text-default mb-4 max-w-2xl text-sm">
|
|
||||||
{t("please_provide_following_text_to_suppport")}:
|
|
||||||
</p>
|
|
||||||
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
|
|
||||||
{searchParams.error}
|
|
||||||
<br />
|
|
||||||
<CopyButton error={searchParams.error} />
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
|
|
||||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
|
||||||
{t("go_back")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error500;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const Page = () => {
|
|
||||||
redirect("/apps/installed/calendar");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const ServerPage = async () => {
|
|
||||||
redirect("/apps/routing-forms/forms");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ServerPage;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { _generateMetadata } from "app/_utils";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
|
||||||
await _generateMetadata(
|
|
||||||
(t) => t("apps"),
|
|
||||||
(t) => t("admin_apps_description"),
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"/settings/admin/apps"
|
|
||||||
);
|
|
||||||
|
|
||||||
const Page = () => {
|
|
||||||
redirect("/settings/admin/apps/calendar");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
+5
-3
@@ -21,11 +21,13 @@ const Page = async () => {
|
|||||||
createRouterCaller(appsRouter),
|
createRouterCaller(appsRouter),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const connectedCalendars = await calendarsCaller.connectedCalendars();
|
const [connectedCalendars, installedCalendars] = await Promise.all([
|
||||||
const installedCalendars = await appsCaller.integrations({
|
calendarsCaller.connectedCalendars(),
|
||||||
|
appsCaller.integrations({
|
||||||
variant: "calendar",
|
variant: "calendar",
|
||||||
onlyInstalled: true,
|
onlyInstalled: true,
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
return (
|
return (
|
||||||
<CalendarListContainer connectedCalendars={connectedCalendars} installedCalendars={installedCalendars} />
|
<CalendarListContainer connectedCalendars={connectedCalendars} installedCalendars={installedCalendars} />
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -21,9 +21,9 @@ export const generateMetadata = async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
const [t, _headers, _cookies] = await Promise.all([getTranslate(), headers(), cookies()]);
|
||||||
|
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
|
||||||
|
|
||||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||||
return redirect("/settings/profile");
|
return redirect("/settings/profile");
|
||||||
|
|||||||
+1
-1
@@ -6,9 +6,9 @@ import type { Resource } from "@calcom/features/pbac/domain/types/permission-reg
|
|||||||
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import { Checkbox, Label } from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
import { Checkbox, Label } from "@calcom/ui/form";
|
|
||||||
|
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button } from "@calcom/ui/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
|
||||||
import { useRoleSheetState } from "../hooks/useRoleQueryStates";
|
import { useRoleSheetState } from "../hooks/useRoleQueryStates";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,10 +14,10 @@ import {
|
|||||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import { Form, TextField, Checkbox, Label } from "@calcom/ui/components/form";
|
||||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
|
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
import { Form, TextField, Checkbox, Label } from "@calcom/ui/form";
|
|
||||||
|
|
||||||
import { revalidateTeamRoles } from "../actions";
|
import { revalidateTeamRoles } from "../actions";
|
||||||
import { AdvancedPermissionGroup } from "./AdvancedPermissionGroup";
|
import { AdvancedPermissionGroup } from "./AdvancedPermissionGroup";
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type { Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
import type { Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Badge } from "@calcom/ui/badge";
|
|
||||||
import { Button } from "@calcom/ui/button";
|
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import {
|
import {
|
||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|||||||
-12
@@ -44,17 +44,5 @@ describe("usePermissions", () => {
|
|||||||
|
|
||||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 'all' for resource with manage permission", () => {
|
|
||||||
const permissions = ["eventType.manage"];
|
|
||||||
|
|
||||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return 'all' for resource with manage permission even if other permissions are missing", () => {
|
|
||||||
const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete
|
|
||||||
|
|
||||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-11
@@ -51,20 +51,14 @@ export function usePermissions(): UsePermissionsReturn {
|
|||||||
return "all";
|
return "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has manage permission for this resource
|
// Filter out internal keys like _resource when checking permissions
|
||||||
const hasManagePermission = permissions.includes(`${resource}.manage`);
|
const allResourcePerms = Object.keys(resourceConfig)
|
||||||
if (hasManagePermission) {
|
.filter((action) => !action.startsWith("_"))
|
||||||
return "all";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out internal keys like _resource and manage when checking for individual permissions
|
|
||||||
const crudPermissions = Object.keys(resourceConfig)
|
|
||||||
.filter((action) => !action.startsWith("_") && action !== "manage")
|
|
||||||
.map((action) => `${resource}.${action}`);
|
.map((action) => `${resource}.${action}`);
|
||||||
const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p));
|
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
|
||||||
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
|
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
|
||||||
|
|
||||||
if (hasAllCrudPerms) return "all";
|
if (hasAllPerms) return "all";
|
||||||
if (hasReadPerm) return "read";
|
if (hasReadPerm) return "read";
|
||||||
return "none";
|
return "none";
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-3
@@ -40,9 +40,7 @@ const getCachedRoles = unstable_cache(
|
|||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
||||||
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
||||||
const attributes = await getCachedAttributes(org.id);
|
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
|
||||||
|
|
||||||
const roles = await getCachedRoles(org.id);
|
|
||||||
|
|
||||||
const facetedTeamValues = {
|
const facetedTeamValues = {
|
||||||
roles,
|
roles,
|
||||||
|
|||||||
@@ -80,6 +80,48 @@ export const _generateMetadata = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const _generateMetadataForStaticPage = (
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
hideBranding?: boolean,
|
||||||
|
origin?: string,
|
||||||
|
pathname?: string
|
||||||
|
) => {
|
||||||
|
const _pathname = pathname ?? "";
|
||||||
|
const canonical = buildCanonical({ path: _pathname, origin: origin ?? CAL_URL });
|
||||||
|
const titleSuffix = `| ${APP_NAME}`;
|
||||||
|
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
|
||||||
|
const metadataBase = new URL(IS_CALCOM ? getCalcomUrl() : WEBAPP_URL);
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
title: title.length === 0 ? APP_NAME : displayedTitle,
|
||||||
|
description,
|
||||||
|
alternates: { canonical },
|
||||||
|
openGraph: {
|
||||||
|
description: truncateOnWord(description, 158),
|
||||||
|
url: canonical,
|
||||||
|
type: "website",
|
||||||
|
siteName: APP_NAME,
|
||||||
|
title: displayedTitle,
|
||||||
|
},
|
||||||
|
metadataBase,
|
||||||
|
};
|
||||||
|
const image =
|
||||||
|
SEO_IMG_OGIMG +
|
||||||
|
constructGenericImage({
|
||||||
|
title: metadata.title,
|
||||||
|
description: metadata.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
openGraph: {
|
||||||
|
...metadata.openGraph,
|
||||||
|
images: [image],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const generateMeetingMetadata = async (
|
export const generateMeetingMetadata = async (
|
||||||
meeting: MeetingImageProps,
|
meeting: MeetingImageProps,
|
||||||
getTitle: (t: TFunction<string, undefined>) => string,
|
getTitle: (t: TFunction<string, undefined>) => string,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||||
import { parseRequestData } from "app/api/parseRequestData";
|
import { parseRequestData } from "app/api/parseRequestData";
|
||||||
|
import * as jose from "jose";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import * as dummy from "openid-client";
|
||||||
import { uuid } from "short-uuid";
|
import { uuid } from "short-uuid";
|
||||||
|
|
||||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
@@ -9,6 +11,10 @@ import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
|
|||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
|
// Need these imports to fix import errors with jackson
|
||||||
|
// https://github.com/ory/polis/blob/main/pages/api/import-hack.ts
|
||||||
|
const unused = dummy; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
const unused2 = jose; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const { oauthController } = await jackson();
|
const { oauthController } = await jackson();
|
||||||
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
|
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||||
|
|
||||||
|
import handler from "@calcom/features/calAIPhone/phoneNumberSubscriptionWebhook";
|
||||||
|
|
||||||
|
export const GET = defaultResponderForAppDir(handler);
|
||||||
@@ -30,6 +30,10 @@ const querySchema = z.object({
|
|||||||
const decryptedSchema = z.object({
|
const decryptedSchema = z.object({
|
||||||
bookingUid: z.string(),
|
bookingUid: z.string(),
|
||||||
userId: z.number().int(),
|
userId: z.number().int(),
|
||||||
|
platformClientId: z.string().optional(),
|
||||||
|
platformRescheduleUrl: z.string().optional(),
|
||||||
|
platformCancelUrl: z.string().optional(),
|
||||||
|
platformBookingUrl: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move the sessionGetter function outside the GET function
|
// Move the sessionGetter function outside the GET function
|
||||||
@@ -64,7 +68,14 @@ async function handler(request: NextRequest) {
|
|||||||
symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")
|
symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const { bookingUid, userId } = decryptedSchema.parse(decryptedData);
|
const {
|
||||||
|
bookingUid,
|
||||||
|
userId,
|
||||||
|
platformClientId,
|
||||||
|
platformRescheduleUrl,
|
||||||
|
platformCancelUrl,
|
||||||
|
platformBookingUrl,
|
||||||
|
} = decryptedSchema.parse(decryptedData);
|
||||||
|
|
||||||
const booking = await prisma.booking.findUniqueOrThrow({
|
const booking = await prisma.booking.findUniqueOrThrow({
|
||||||
where: { uid: bookingUid },
|
where: { uid: bookingUid },
|
||||||
@@ -97,6 +108,14 @@ async function handler(request: NextRequest) {
|
|||||||
recurringEventId: booking.recurringEventId || undefined,
|
recurringEventId: booking.recurringEventId || undefined,
|
||||||
confirmed: action === DirectAction.ACCEPT,
|
confirmed: action === DirectAction.ACCEPT,
|
||||||
reason,
|
reason,
|
||||||
|
platformClientParams: platformClientId
|
||||||
|
? {
|
||||||
|
platformClientId,
|
||||||
|
platformRescheduleUrl,
|
||||||
|
platformCancelUrl,
|
||||||
|
platformBookingUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let message = "Error confirming booking";
|
let message = "Error confirming booking";
|
||||||
|
|||||||
@@ -193,25 +193,27 @@ async function getHandler(request: NextRequest) {
|
|||||||
const response = await fetch(filteredLogo);
|
const response = await fetch(filteredLogo);
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
let buffer: Buffer = Buffer.from(arrayBuffer);
|
let buffer: Buffer = Buffer.from(arrayBuffer);
|
||||||
|
let contentType = response.headers.get("content-type") || "image/png";
|
||||||
|
|
||||||
// If we need to resize the team logos (via Next.js' built-in image processing)
|
// Resize the team logos if needed
|
||||||
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
|
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
|
||||||
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
|
const { resizeImage } = await import("@calcom/lib/server/imageUtils");
|
||||||
|
const { buffer: outBuffer, contentType: outContentType } = await resizeImage({
|
||||||
buffer = await optimizeImage({
|
|
||||||
buffer,
|
buffer,
|
||||||
contentType: (await detectContentType(buffer)) ?? "image/jpeg",
|
|
||||||
quality: 100,
|
|
||||||
width: logoDefinition.w,
|
width: logoDefinition.w,
|
||||||
height: logoDefinition.h, // optional
|
height: logoDefinition.h,
|
||||||
|
quality: 100,
|
||||||
|
contentType,
|
||||||
});
|
});
|
||||||
|
buffer = outBuffer;
|
||||||
|
contentType = outContentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new response with the image buffer
|
// Create a new response with the image buffer
|
||||||
const imageResponse = new NextResponse(buffer as BodyInit);
|
const imageResponse = new NextResponse(buffer as BodyInit);
|
||||||
|
|
||||||
// Set the appropriate headers
|
// Set the appropriate headers
|
||||||
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
imageResponse.headers.set("Content-Type", contentType);
|
||||||
imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
|
||||||
|
|
||||||
return imageResponse;
|
return imageResponse;
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
async function getHandler() {
|
|
||||||
return NextResponse.json({ message: "Please don't" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postHandler() {
|
|
||||||
return NextResponse.json({ message: "Please don't" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET = defaultResponderForAppDir(getHandler);
|
|
||||||
export const POST = defaultResponderForAppDir(postHandler);
|
|
||||||
@@ -12,7 +12,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
|||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
|
|||||||
if (!team) {
|
if (!team) {
|
||||||
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
|
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
|
||||||
|
|
||||||
metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
|
metadata = teamMetadataStrictSchema.safeParse(prevTeam.metadata);
|
||||||
if (!metadata.success) {
|
if (!metadata.success) {
|
||||||
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
|
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
metadata = teamMetadataSchema.safeParse(team.metadata);
|
metadata = teamMetadataStrictSchema.safeParse(team.metadata);
|
||||||
if (!metadata.success) {
|
if (!metadata.success) {
|
||||||
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
|
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { Retell } from "retell-sdk";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||||
|
import type { CalAiPhoneNumber, User, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
import { POST } from "../route";
|
||||||
|
|
||||||
|
type MockPhoneNumberWithUser = Omit<CalAiPhoneNumber, "user" | "team"> & {
|
||||||
|
user: Pick<User, "id" | "email" | "name">;
|
||||||
|
team: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockPhoneNumberWithTeam = Omit<CalAiPhoneNumber, "user" | "team"> & {
|
||||||
|
user: null;
|
||||||
|
team: Pick<Team, "id" | "name">;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RetellWebhookBody = {
|
||||||
|
event: "call_started" | "call_ended" | "call_analyzed";
|
||||||
|
call: {
|
||||||
|
call_id: string;
|
||||||
|
agent_id?: string;
|
||||||
|
from_number?: string;
|
||||||
|
to_number?: string;
|
||||||
|
direction?: "inbound" | "outbound";
|
||||||
|
call_status?: string;
|
||||||
|
start_timestamp?: number;
|
||||||
|
end_timestamp?: number;
|
||||||
|
disconnection_reason?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
retell_llm_dynamic_variables?: Record<string, unknown>;
|
||||||
|
transcript?: string;
|
||||||
|
opt_out_sensitive_data_storage?: boolean;
|
||||||
|
call_cost?: {
|
||||||
|
product_costs?: Array<{
|
||||||
|
product: string;
|
||||||
|
unitPrice?: number;
|
||||||
|
cost?: number;
|
||||||
|
}>;
|
||||||
|
total_duration_seconds?: number;
|
||||||
|
total_duration_unit_price?: number;
|
||||||
|
total_one_time_price?: number;
|
||||||
|
combined_cost?: number;
|
||||||
|
};
|
||||||
|
call_analysis?: {
|
||||||
|
call_summary?: string;
|
||||||
|
in_voicemail?: boolean;
|
||||||
|
user_sentiment?: string;
|
||||||
|
call_successful?: boolean;
|
||||||
|
custom_analysis_data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||||
|
defaultResponderForAppDir:
|
||||||
|
(
|
||||||
|
handler: (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<Response>
|
||||||
|
) =>
|
||||||
|
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) =>
|
||||||
|
await handler(req, context || { params: Promise.resolve({}) }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("retell-sdk", () => ({
|
||||||
|
Retell: {
|
||||||
|
verify: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockHasAvailableCredits = vi.fn();
|
||||||
|
const mockChargeCredits = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/ee/billing/credit-service", () => ({
|
||||||
|
CreditService: vi.fn().mockImplementation(() => ({
|
||||||
|
hasAvailableCredits: mockHasAvailableCredits,
|
||||||
|
chargeCredits: mockChargeCredits,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
|
||||||
|
PrismaPhoneNumberRepository: {
|
||||||
|
findByPhoneNumber: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/server", () => ({
|
||||||
|
NextResponse: {
|
||||||
|
json: vi.fn((data, options) => ({
|
||||||
|
json: () => Promise.resolve(data),
|
||||||
|
status: options?.status || 200,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createMockRequest = (body: RetellWebhookBody, signature?: string): NextRequest => {
|
||||||
|
const request = {
|
||||||
|
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) => (name === "x-retell-signature" ? signature : null)),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
|
const callPOST = (request: NextRequest) => POST(request, { params: Promise.resolve({}) });
|
||||||
|
|
||||||
|
describe("Retell AI Webhook Handler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubEnv("RETELL_AI_KEY", "test-api-key");
|
||||||
|
vi.stubEnv("CAL_AI_CALL_RATE_PER_MINUTE", "0.29");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 401 when signature is missing", async () => {
|
||||||
|
const request = createMockRequest(
|
||||||
|
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
|
||||||
|
"some-signature"
|
||||||
|
);
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 401 when API key is not configured", async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
|
||||||
|
const request = createMockRequest(
|
||||||
|
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
|
||||||
|
"some-signature"
|
||||||
|
);
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
|
||||||
|
// Restore the stubbed env for subsequent tests
|
||||||
|
vi.stubEnv("RETELL_AI_KEY", "test-api-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 401 when signature is invalid", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(false);
|
||||||
|
|
||||||
|
const request = createMockRequest(
|
||||||
|
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
|
||||||
|
"invalid-signature"
|
||||||
|
);
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-call_analyzed events", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_started",
|
||||||
|
call: { call_id: "test-call-id" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.message).toContain("No handling for call_started");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process call_analyzed event with valid phone number and sufficient credits", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 1,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
|
||||||
|
mockHasAvailableCredits.mockResolvedValue(true);
|
||||||
|
mockChargeCredits.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "test-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
total_duration_seconds: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 1,
|
||||||
|
teamId: undefined,
|
||||||
|
credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits
|
||||||
|
callDuration: 120,
|
||||||
|
externalRef: "retell:test-call-id",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle team phone numbers", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const mockTeamPhoneNumber: MockPhoneNumberWithTeam = {
|
||||||
|
id: 2,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: null,
|
||||||
|
teamId: 5,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id-2",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
team: { id: 5, name: "Test Team" },
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber);
|
||||||
|
|
||||||
|
mockHasAvailableCredits.mockResolvedValue(true);
|
||||||
|
mockChargeCredits.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "test-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 50,
|
||||||
|
total_duration_seconds: 180,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: undefined,
|
||||||
|
teamId: 5,
|
||||||
|
credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits
|
||||||
|
callDuration: 180,
|
||||||
|
externalRef: "retell:test-call-id",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing total_duration_seconds", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "test-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
// missing total_duration_seconds
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle phone number not found", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "test-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
total_duration_seconds: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockChargeCredits).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle insufficient credits", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 3,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id-3",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
|
||||||
|
mockHasAvailableCredits.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "test-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
total_duration_seconds: 150,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle schema validation errors", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
// Missing required fields like from_number, to_number, etc.
|
||||||
|
call_id: "test-call-id",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
const response = await callPOST(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.error).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate credits correctly based on duration", async () => {
|
||||||
|
const testCases = [
|
||||||
|
{ durationSeconds: 60, expectedCredits: 29 }, // 1 minute * $0.29 = $0.29 = 29 credits
|
||||||
|
{ durationSeconds: 120, expectedCredits: 58 }, // 2 minutes * $0.29 = $0.58 = 58 credits
|
||||||
|
{ durationSeconds: 150, expectedCredits: 73 }, // 2.5 minutes * $0.29 = $0.725 = 73 credits (rounded up)
|
||||||
|
{ durationSeconds: 30, expectedCredits: 15 }, // 0.5 minutes * $0.29 = $0.145 = 15 credits (rounded up)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { durationSeconds, expectedCredits } of testCases) {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 4,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id-4",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
mockHasAvailableCredits.mockResolvedValue(true);
|
||||||
|
mockChargeCredits.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: `test-call-${durationSeconds}s`,
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100, // This is ignored now
|
||||||
|
total_duration_seconds: durationSeconds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createMockRequest(body, "valid-signature");
|
||||||
|
await callPOST(request);
|
||||||
|
|
||||||
|
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 1,
|
||||||
|
teamId: undefined,
|
||||||
|
credits: expectedCredits,
|
||||||
|
callDuration: durationSeconds,
|
||||||
|
externalRef: expect.stringMatching(/^retell:test-call-/),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass callDuration to chargeCredits when provided", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 10,
|
||||||
|
phoneNumber: "+15550001111",
|
||||||
|
userId: 42,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "prov-10",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 42, email: "u@example.com", name: "U" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
mockHasAvailableCredits.mockResolvedValue(true);
|
||||||
|
mockChargeCredits.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "call-dur",
|
||||||
|
from_number: "+15550002222",
|
||||||
|
to_number: "+15550001111",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 123,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 12, // This is ignored now
|
||||||
|
total_duration_seconds: 125,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await callPOST(createMockRequest(body, "valid-signature"));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ userId: 42, credits: 61, callDuration: 125 }) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Idempotency", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass externalRef with correct format to chargeCredits", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 1,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
mockChargeCredits.mockResolvedValue({ userId: 1 });
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "test-idempotency-call",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
total_duration_seconds: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await callPOST(createMockRequest(body, "valid-signature"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockChargeCredits).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 1,
|
||||||
|
teamId: undefined,
|
||||||
|
credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits
|
||||||
|
callDuration: 60,
|
||||||
|
externalRef: "retell:test-idempotency-call",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle duplicate webhook calls idempotently", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 1,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "duplicate-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
total_duration_seconds: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call - should charge normally
|
||||||
|
mockChargeCredits.mockResolvedValue({ userId: 1 });
|
||||||
|
const response1 = await callPOST(createMockRequest(body, "valid-signature"));
|
||||||
|
expect(response1.status).toBe(200);
|
||||||
|
const data1 = await response1.json();
|
||||||
|
expect(data1.success).toBe(true);
|
||||||
|
expect(data1.message).toContain("Successfully charged 29 credits");
|
||||||
|
|
||||||
|
// Second call - should return duplicate
|
||||||
|
mockChargeCredits.mockResolvedValue({ bookingUid: null, duplicate: true });
|
||||||
|
const response2 = await callPOST(createMockRequest(body, "valid-signature"));
|
||||||
|
expect(response2.status).toBe(200);
|
||||||
|
const data2 = await response2.json();
|
||||||
|
expect(data2.success).toBe(true);
|
||||||
|
expect(data2.message).toContain("Successfully charged 29 credits");
|
||||||
|
|
||||||
|
// Verify chargeCredits was called twice with same externalRef
|
||||||
|
expect(mockChargeCredits).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockChargeCredits).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ externalRef: "retell:duplicate-call-id" })
|
||||||
|
);
|
||||||
|
expect(mockChargeCredits).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ externalRef: "retell:duplicate-call-id" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle errors from chargeCredits gracefully", async () => {
|
||||||
|
vi.mocked(Retell.verify).mockReturnValue(true);
|
||||||
|
const mockPhoneNumber: MockPhoneNumberWithUser = {
|
||||||
|
id: 1,
|
||||||
|
phoneNumber: "+1234567890",
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
provider: "test-provider",
|
||||||
|
providerPhoneNumberId: "test-provider-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
inboundAgentId: null,
|
||||||
|
outboundAgentId: null,
|
||||||
|
user: { id: 1, email: "test@example.com", name: "Test User" },
|
||||||
|
team: null,
|
||||||
|
};
|
||||||
|
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
|
||||||
|
|
||||||
|
// Mock chargeCredits to throw an error
|
||||||
|
mockChargeCredits.mockRejectedValue(new Error("Credit service error"));
|
||||||
|
|
||||||
|
const body: RetellWebhookBody = {
|
||||||
|
event: "call_analyzed",
|
||||||
|
call: {
|
||||||
|
call_id: "error-call-id",
|
||||||
|
from_number: "+1234567890",
|
||||||
|
to_number: "+0987654321",
|
||||||
|
direction: "outbound",
|
||||||
|
call_status: "completed",
|
||||||
|
start_timestamp: 1234567890,
|
||||||
|
call_cost: {
|
||||||
|
combined_cost: 100,
|
||||||
|
total_duration_seconds: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await callPOST(createMockRequest(body, "valid-signature"));
|
||||||
|
|
||||||
|
// Should still return 200 to prevent retries
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.message).toContain("Error charging credits for Retell AI call");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Retell } from "retell-sdk";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
|
||||||
|
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
|
||||||
|
|
||||||
|
const RetellWebhookSchema = z.object({
|
||||||
|
event: z.enum(["call_started", "call_ended", "call_analyzed"]),
|
||||||
|
call: z
|
||||||
|
.object({
|
||||||
|
call_id: z.string(),
|
||||||
|
agent_id: z.string().optional(),
|
||||||
|
from_number: z.string(),
|
||||||
|
to_number: z.string(),
|
||||||
|
direction: z.enum(["inbound", "outbound"]),
|
||||||
|
call_status: z.string(),
|
||||||
|
start_timestamp: z.number(),
|
||||||
|
end_timestamp: z.number().optional(),
|
||||||
|
disconnection_reason: z.string().optional(),
|
||||||
|
metadata: z.record(z.any()).optional(),
|
||||||
|
retell_llm_dynamic_variables: z.record(z.any()).optional(),
|
||||||
|
transcript: z.string().optional(),
|
||||||
|
opt_out_sensitive_data_storage: z.boolean().optional(),
|
||||||
|
call_cost: z
|
||||||
|
.object({
|
||||||
|
product_costs: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
product: z.string(),
|
||||||
|
unitPrice: z.number().optional(),
|
||||||
|
cost: z.number().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
total_duration_seconds: z.number().optional(),
|
||||||
|
total_duration_unit_price: z.number().optional(),
|
||||||
|
total_one_time_price: z.number().optional(),
|
||||||
|
combined_cost: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
call_analysis: z
|
||||||
|
.object({
|
||||||
|
call_summary: z.string().optional(),
|
||||||
|
in_voicemail: z.boolean().optional(),
|
||||||
|
user_sentiment: z.string().optional(),
|
||||||
|
call_successful: z.boolean().optional(),
|
||||||
|
custom_analysis_data: z.record(z.any()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleCallAnalyzed(callData: any) {
|
||||||
|
const { from_number, call_id, call_cost } = callData;
|
||||||
|
if (
|
||||||
|
!call_cost ||
|
||||||
|
typeof call_cost.total_duration_seconds !== "number" ||
|
||||||
|
!Number.isFinite(call_cost.total_duration_seconds) ||
|
||||||
|
call_cost.total_duration_seconds <= 0
|
||||||
|
) {
|
||||||
|
log.error(
|
||||||
|
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({ phoneNumber: from_number });
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
log.error(`No phone number found for ${from_number}, cannot deduct credits`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support both personal and team phone numbers
|
||||||
|
const userId = phoneNumber.userId;
|
||||||
|
const teamId = phoneNumber.teamId;
|
||||||
|
|
||||||
|
if (!userId && !teamId) {
|
||||||
|
log.error(`Phone number ${from_number} has no associated user or team`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRatePerMinute = process.env.CAL_AI_CALL_RATE_PER_MINUTE ?? "0.29";
|
||||||
|
const ratePerMinute = Number.parseFloat(rawRatePerMinute);
|
||||||
|
const safeRatePerMinute = Number.isFinite(ratePerMinute) && ratePerMinute > 0 ? ratePerMinute : 0.29;
|
||||||
|
|
||||||
|
const durationInMinutes = call_cost.total_duration_seconds / 60;
|
||||||
|
const callCost = durationInMinutes * safeRatePerMinute;
|
||||||
|
// Convert to cents and round up to ensure we don't undercharge
|
||||||
|
const creditsToDeduct = Math.ceil(callCost * 100);
|
||||||
|
|
||||||
|
const creditService = new CreditService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await creditService.chargeCredits({
|
||||||
|
userId: userId ?? undefined,
|
||||||
|
teamId: teamId ?? undefined,
|
||||||
|
credits: creditsToDeduct,
|
||||||
|
callDuration: call_cost.total_duration_seconds,
|
||||||
|
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
|
||||||
|
externalRef: `retell:${call_id}`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error charging credits for Retell AI call", {
|
||||||
|
error: e,
|
||||||
|
call_id,
|
||||||
|
call_cost,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Error charging credits for Retell AI call: ${
|
||||||
|
e instanceof Error ? e.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully charged ${creditsToDeduct} credits (${
|
||||||
|
call_cost.total_duration_seconds
|
||||||
|
}s at $${safeRatePerMinute}/min) for ${teamId ? `team:${teamId}` : ""} ${
|
||||||
|
userId ? `user:${userId}` : ""
|
||||||
|
}, call ${call_id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retell AI Webhook Handler
|
||||||
|
*
|
||||||
|
* Setup Instructions:
|
||||||
|
* 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai
|
||||||
|
* 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar)
|
||||||
|
* 3. Set the RETELL_AI_KEY environment variable with your Retell API key (must have webhook badge)
|
||||||
|
*
|
||||||
|
* This webhook will:
|
||||||
|
* - Verify webhook signature for security
|
||||||
|
* - Receive call_analyzed events from Retell AI
|
||||||
|
* - Charge credits based on the call cost from the user's or team's credit balance
|
||||||
|
* - Log all transactions for audit purposes
|
||||||
|
*/
|
||||||
|
async function handler(request: NextRequest) {
|
||||||
|
const rawBody = await request.text();
|
||||||
|
const body = JSON.parse(rawBody);
|
||||||
|
|
||||||
|
// Verify webhook signature
|
||||||
|
const signature = request.headers.get("x-retell-signature");
|
||||||
|
const apiKey = process.env.RETELL_AI_KEY;
|
||||||
|
|
||||||
|
if (!signature || !apiKey) {
|
||||||
|
log.error("Missing signature or API key for webhook verification");
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "Missing signature or API key",
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Retell.verify(rawBody, apiKey, signature)) {
|
||||||
|
log.error("Invalid webhook signature");
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "Invalid signature",
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.event !== "call_analyzed") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = RetellWebhookSchema.parse(body);
|
||||||
|
const callData = payload.call;
|
||||||
|
if (callData.direction === "inbound") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: `Inbound calls are not charged or supported for now. Ignoring call ${callData.call_id}`,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`);
|
||||||
|
|
||||||
|
const result = await handleCallAnalyzed(callData);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: result?.success ?? true,
|
||||||
|
message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error processing Retell AI webhook:", safeStringify(error));
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Internal server error",
|
||||||
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
// we need to return 200 to retell ai to avoid retries
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = defaultResponderForAppDir(handler);
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
redirect(process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com");
|
|
||||||
}
|
|
||||||
+14
-13
@@ -1,4 +1,5 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
import { _generateMetadataForStaticPage } from "app/_utils";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
@@ -8,6 +9,15 @@ import type { IconName } from "@calcom/ui/components/icon";
|
|||||||
import { lucideIconList } from "../../../../packages/ui/components/icon/icon-list.mjs";
|
import { lucideIconList } from "../../../../packages/ui/components/icon/icon-list.mjs";
|
||||||
import { IconGrid } from "./IconGrid";
|
import { IconGrid } from "./IconGrid";
|
||||||
|
|
||||||
|
export const dynamic = "force-static";
|
||||||
|
export const metadata: Metadata = _generateMetadataForStaticPage(
|
||||||
|
"Icons Showcase",
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/icons"
|
||||||
|
);
|
||||||
|
|
||||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||||
const calFont = localFont({
|
const calFont = localFont({
|
||||||
src: "../../fonts/CalSans-SemiBold.woff2",
|
src: "../../fonts/CalSans-SemiBold.woff2",
|
||||||
@@ -16,25 +26,16 @@ const calFont = localFont({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
weight: "600",
|
weight: "600",
|
||||||
});
|
});
|
||||||
export const generateMetadata = async () => {
|
|
||||||
return await _generateMetadata(
|
export default function IconsPage() {
|
||||||
(t) => t("icon_showcase"),
|
|
||||||
() => "",
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"/icons"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default async function IconsPage() {
|
|
||||||
const icons = Array.from(lucideIconList).sort() as IconName[];
|
const icons = Array.from(lucideIconList).sort() as IconName[];
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${interFont.variable} ${calFont.variable}`}>
|
<div className={`${interFont.variable} ${calFont.variable}`}>
|
||||||
<div className="bg-subtle flex h-screen">
|
<div className="bg-subtle flex h-screen">
|
||||||
<IconSprites />
|
<IconSprites />
|
||||||
<div className="bg-default m-auto min-w-full rounded-md p-10 text-right ltr:text-left">
|
<div className="bg-default m-auto min-w-full rounded-md p-10 text-right ltr:text-left">
|
||||||
<h1 className="text-emphasis font-cal text-2xl font-medium">{t("icons_showcase")}</h1>
|
<h1 className="text-emphasis font-cal text-2xl font-medium">Icons Showcase</h1>
|
||||||
<IconGrid title="Regular Icons" icons={icons} />
|
<IconGrid title="Regular Icons" icons={icons} />
|
||||||
<IconGrid
|
<IconGrid
|
||||||
title="Filled Icons"
|
title="Filled Icons"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import CacheProvider from "react-inlinesvg/provider";
|
import CacheProvider from "react-inlinesvg/provider";
|
||||||
|
|
||||||
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
|
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
|
||||||
@@ -17,11 +18,13 @@ type ProvidersProps = {
|
|||||||
};
|
};
|
||||||
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
|
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
|
||||||
const isBookingPage = useIsBookingPage();
|
const isBookingPage = useIsBookingPage();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isOnboardingPage = pathname?.startsWith("/getting-started");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<TrpcProvider>
|
<TrpcProvider>
|
||||||
{!isBookingPage ? <PlainChat nonce={nonce} /> : null}
|
{!isBookingPage && !isOnboardingPage ? <PlainChat nonce={nonce} /> : null}
|
||||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||||
<CacheProvider>
|
<CacheProvider>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const Page = () => {
|
|
||||||
redirect("/apps/routing-forms/forms");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -218,7 +218,7 @@ export const ReassignDialog = ({
|
|||||||
placeholder={t("search")}
|
placeholder={t("search")}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
|
<div className="scroll-bar mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
|
||||||
{teamMemberOptions.map((member) => (
|
{teamMemberOptions.map((member) => (
|
||||||
<label
|
<label
|
||||||
key={member.value}
|
key={member.value}
|
||||||
|
|||||||
@@ -539,18 +539,11 @@ const NewRoutingManager = ({
|
|||||||
url: chosenEventUrls.eventBookingAbsoluteUrl,
|
url: chosenEventUrls.eventBookingAbsoluteUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("SETTING NEW VALUE", {
|
|
||||||
type: "reschedule_to_different_event_new_tab",
|
|
||||||
reschedulerWindow,
|
|
||||||
newBooking: null,
|
|
||||||
});
|
|
||||||
reroutingState.setValue({
|
reroutingState.setValue({
|
||||||
type: "reschedule_to_different_event_new_tab",
|
type: "reschedule_to_different_event_new_tab",
|
||||||
reschedulerWindow,
|
reschedulerWindow,
|
||||||
newBooking: null,
|
newBooking: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("VALUE NOW", JSON.stringify(reroutingState));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reroutingPreview() {
|
function reroutingPreview() {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
|
|||||||
{t("this_app_requires_connected_account", {
|
{t("this_app_requires_connected_account", {
|
||||||
appName: title,
|
appName: title,
|
||||||
dependencyName: dependency.name,
|
dependencyName: dependency.name,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
|||||||
loading={isPageLoading}
|
loading={isPageLoading}
|
||||||
onClick={() => nextStep()}
|
onClick={() => nextStep()}
|
||||||
disabled={disabledNextButton}>
|
disabled={disabledNextButton}>
|
||||||
{firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`}
|
{firstCalendar ? `${t("connect_your_video")}` : `${t("connect_calendar_first")}`}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { type TFunction } from "i18next";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
@@ -20,11 +19,9 @@ interface ConnectedAppStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ConnectedVideoStepInner = ({
|
const ConnectedVideoStepInner = ({
|
||||||
t,
|
|
||||||
setAnyInstalledVideoApps,
|
setAnyInstalledVideoApps,
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
t: TFunction;
|
|
||||||
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
|
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
|
||||||
user: RouterOutputs["viewer"]["me"]["get"];
|
user: RouterOutputs["viewer"]["me"]["get"];
|
||||||
}) => {
|
}) => {
|
||||||
@@ -94,7 +91,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
|||||||
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
|
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
|
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} user={user} />
|
||||||
<Button
|
<Button
|
||||||
EndIcon="arrow-right"
|
EndIcon="arrow-right"
|
||||||
data-testid="save-video-button"
|
data-testid="save-video-button"
|
||||||
@@ -105,7 +102,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
|||||||
disabled={!hasAnyInstalledVideoApps}
|
disabled={!hasAnyInstalledVideoApps}
|
||||||
loading={isPageLoading}
|
loading={isPageLoading}
|
||||||
onClick={() => nextStep()}>
|
onClick={() => nextStep()}>
|
||||||
{t("next_step_text")}
|
{t("set_availability")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||||||
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
|
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
|
||||||
loading={availabilityForm.formState.isSubmitting}
|
loading={availabilityForm.formState.isSubmitting}
|
||||||
disabled={availabilityForm.formState.isSubmitting}>
|
disabled={availabilityForm.formState.isSubmitting}>
|
||||||
{t("next_step_text")}
|
{t("complete_profile")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const UserProfile = ({ user }: UserProfileProps) => {
|
|||||||
EndIcon="arrow-right"
|
EndIcon="arrow-right"
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-8 w-full items-center justify-center">
|
className="mt-8 w-full items-center justify-center">
|
||||||
{t("finish")}
|
{t("finish_and_start")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
/>
|
/>
|
||||||
@@ -121,7 +122,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
className="mt-8 flex w-full flex-row justify-center"
|
className="mt-8 flex w-full flex-row justify-center"
|
||||||
loading={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
disabled={mutation.isPending}>
|
disabled={mutation.isPending}>
|
||||||
{t("next_step_text")}
|
{t("connect_your_calendar")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export function NotificationSoundHandler() {
|
|||||||
const response = await fetch("/ring.mp3");
|
const response = await fetch("/ring.mp3");
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
audioBufferRef.current = await audioContextRef.current.decodeAudioData(arrayBuffer);
|
audioBufferRef.current = await audioContextRef.current.decodeAudioData(arrayBuffer);
|
||||||
console.log("Audio file loaded and decoded");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -62,7 +61,6 @@ export function NotificationSoundHandler() {
|
|||||||
|
|
||||||
source.loop = true;
|
source.loop = true;
|
||||||
source.start(0);
|
source.start(0);
|
||||||
console.log("Sound started playing");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (sourceRef.current === source) {
|
if (sourceRef.current === source) {
|
||||||
@@ -101,7 +99,7 @@ export function NotificationSoundHandler() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!("serviceWorker" in navigator)) {
|
if (!("serviceWorker" in navigator)) {
|
||||||
console.log("ServiceWorker not available");
|
console.warn("ServiceWorker not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ import { Icon } from "@calcom/ui/components/icon";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
|
||||||
interface ContactFormData {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
message: string;
|
|
||||||
attachments?: FileData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlainContactForm = () => {
|
const PlainContactForm = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
<TextField
|
<TextField
|
||||||
ref={usernameRef}
|
ref={usernameRef}
|
||||||
name="username"
|
name="username"
|
||||||
|
placeholder="john"
|
||||||
value={inputUsernameValue}
|
value={inputUsernameValue}
|
||||||
autoComplete="none"
|
autoComplete="none"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { APP_NAME } from "@calcom/lib/constants";
|
|||||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { hasPermission } from "../../../../../packages/platform/utils/permissions";
|
import { hasPermission } from "../../../../../packages/platform/utils/permissions";
|
||||||
|
|
||||||
@@ -32,8 +32,6 @@ export default function Authorize() {
|
|||||||
permissions: 7,
|
permissions: 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("These are the search params:", queryString);
|
|
||||||
|
|
||||||
const permissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value) => {
|
const permissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value) => {
|
||||||
let permissionsMessage = "";
|
let permissionsMessage = "";
|
||||||
const hasReadPermission = hasPermission(client.permissions, value.read);
|
const hasReadPermission = hasPermission(client.permissions, value.read);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SystemField,
|
SystemField,
|
||||||
TITLE_FIELD,
|
TITLE_FIELD,
|
||||||
} from "@calcom/features/bookings/lib/SystemField";
|
} from "@calcom/features/bookings/lib/SystemField";
|
||||||
|
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
|
||||||
import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks";
|
import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
||||||
@@ -179,14 +180,13 @@ export default function Success(props: PageProps) {
|
|||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const parsedRating = rating ? parseInt(rating, 10) : 3;
|
|
||||||
const currentUserEmail =
|
const currentUserEmail =
|
||||||
searchParams?.get("rescheduledBy") ??
|
searchParams?.get("rescheduledBy") ??
|
||||||
searchParams?.get("cancelledBy") ??
|
searchParams?.get("cancelledBy") ??
|
||||||
session?.user?.email ??
|
session?.user?.email ??
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
const defaultRating = isNaN(parsedRating) ? 3 : parsedRating > 5 ? 5 : parsedRating < 1 ? 1 : parsedRating;
|
const defaultRating = validateRating(rating);
|
||||||
const [rateValue, setRateValue] = useState<number>(defaultRating);
|
const [rateValue, setRateValue] = useState<number>(defaultRating);
|
||||||
const [isFeedbackSubmitted, setIsFeedbackSubmitted] = useState(false);
|
const [isFeedbackSubmitted, setIsFeedbackSubmitted] = useState(false);
|
||||||
|
|
||||||
@@ -1026,61 +1026,20 @@ export default function Success(props: PageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="my-3 flex justify-center space-x-1">
|
<div className="my-3 flex justify-center space-x-1">
|
||||||
|
{RATING_OPTIONS.map((option) => (
|
||||||
<button
|
<button
|
||||||
|
key={option.value}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
|
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
|
||||||
rateValue === 1
|
rateValue === option.value
|
||||||
? "border-emphasis bg-emphasis"
|
? "border-emphasis bg-emphasis"
|
||||||
: "border-muted bg-default opacity-50"
|
: "border-muted bg-default opacity-50"
|
||||||
)}
|
)}
|
||||||
disabled={isFeedbackSubmitted}
|
disabled={isFeedbackSubmitted}
|
||||||
onClick={() => setRateValue(1)}>
|
onClick={() => setRateValue(option.value)}>
|
||||||
😠
|
{option.emoji}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
|
|
||||||
rateValue === 2
|
|
||||||
? "border-emphasis bg-emphasis"
|
|
||||||
: "border-muted bg-default opacity-50"
|
|
||||||
)}
|
|
||||||
disabled={isFeedbackSubmitted}
|
|
||||||
onClick={() => setRateValue(2)}>
|
|
||||||
🙁
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
|
|
||||||
rateValue === 3
|
|
||||||
? "border-emphasis bg-emphasis"
|
|
||||||
: " border-muted bg-default opacity-50"
|
|
||||||
)}
|
|
||||||
disabled={isFeedbackSubmitted}
|
|
||||||
onClick={() => setRateValue(3)}>
|
|
||||||
😐
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
|
|
||||||
rateValue === 4
|
|
||||||
? "border-emphasis bg-emphasis"
|
|
||||||
: "border-muted bg-default opacity-50"
|
|
||||||
)}
|
|
||||||
disabled={isFeedbackSubmitted}
|
|
||||||
onClick={() => setRateValue(4)}>
|
|
||||||
😄
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
"flex h-10 w-10 items-center justify-center rounded-full border text-2xl hover:opacity-100",
|
|
||||||
rateValue === 5
|
|
||||||
? "border-emphasis bg-emphasis"
|
|
||||||
: "border-muted bg-default opacity-50"
|
|
||||||
)}
|
|
||||||
disabled={isFeedbackSubmitted}
|
|
||||||
onClick={() => setRateValue(5)}>
|
|
||||||
😍
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="my-4 space-y-1 text-center">
|
<div className="my-4 space-y-1 text-center">
|
||||||
<h2 className="font-cal text-lg">{t("submitted_feedback")}</h2>
|
<h2 className="font-cal text-lg">{t("submitted_feedback")}</h2>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const getStepsAndHeadersForUser = (t: TFunction) => {
|
|||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
title: t("welcome_to_cal_header", { appName: APP_NAME }),
|
title: t("welcome_to_cal_header", { appName: APP_NAME }),
|
||||||
subtitle: [t("we_just_need_basic_info"), t("edit_form_later_subtitle")],
|
subtitle: [t("we_just_need_basic_info")],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("connect_your_calendar"),
|
title: t("connect_your_calendar"),
|
||||||
@@ -57,8 +57,9 @@ const getStepsAndHeadersForUser = (t: TFunction) => {
|
|||||||
{
|
{
|
||||||
title: t("set_availability"),
|
title: t("set_availability"),
|
||||||
subtitle: [
|
subtitle: [
|
||||||
t("set_availability_getting_started_subtitle_1"),
|
`${t("set_availability_getting_started_subtitle_1")} ${t(
|
||||||
t("set_availability_getting_started_subtitle_2"),
|
"set_availability_getting_started_subtitle_2"
|
||||||
|
)}`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import {
|
|||||||
LowestRatedMembersTable,
|
LowestRatedMembersTable,
|
||||||
MostBookedTeamMembersTable,
|
MostBookedTeamMembersTable,
|
||||||
MostCancelledBookingsTables,
|
MostCancelledBookingsTables,
|
||||||
|
MostCompletedTeamMembersTable,
|
||||||
|
LeastCompletedTeamMembersTable,
|
||||||
PopularEventsTable,
|
PopularEventsTable,
|
||||||
|
RecentNoShowGuestsChart,
|
||||||
RecentFeedbackTable,
|
RecentFeedbackTable,
|
||||||
TimezoneBadge,
|
TimezoneBadge,
|
||||||
} from "@calcom/features/insights/components/booking";
|
} from "@calcom/features/insights/components/booking";
|
||||||
@@ -82,25 +85,34 @@ function InsightsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<PopularEventsTable />
|
|
||||||
</div>
|
|
||||||
<MostBookedTeamMembersTable />
|
<MostBookedTeamMembersTable />
|
||||||
<LeastBookedTeamMembersTable />
|
<LeastBookedTeamMembersTable />
|
||||||
|
<MostCompletedTeamMembersTable />
|
||||||
|
<LeastCompletedTeamMembersTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||||
<MostCancelledBookingsTables />
|
<MostCancelledBookingsTables />
|
||||||
<HighestNoShowHostTable />
|
<HighestNoShowHostTable />
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<RecentNoShowGuestsChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||||
<HighestRatedMembersTable />
|
<HighestRatedMembersTable />
|
||||||
<LowestRatedMembersTable />
|
<LowestRatedMembersTable />
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<RecentFeedbackTable />
|
<RecentFeedbackTable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<PopularEventsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<small className="text-default block text-center">
|
<small className="text-default block text-center">
|
||||||
{t("looking_for_more_insights")}{" "}
|
{t("looking_for_more_insights")}{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||||
@@ -16,7 +18,7 @@ import { Button } from "@calcom/ui/components/button";
|
|||||||
import { Select } from "@calcom/ui/components/form";
|
import { Select } from "@calcom/ui/components/form";
|
||||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||||
import { showToast } from "@calcom/ui/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
|
||||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||||
|
|
||||||
@@ -27,7 +29,6 @@ type MonthOption = {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// returns the last 12 months starting from May 2025 (when credits were introduced)
|
|
||||||
const getMonthOptions = (): MonthOption[] => {
|
const getMonthOptions = (): MonthOption[] => {
|
||||||
const options: MonthOption[] = [];
|
const options: MonthOption[] = [];
|
||||||
const minDate = dayjs.utc("2025-05-01");
|
const minDate = dayjs.utc("2025-05-01");
|
||||||
@@ -53,6 +54,9 @@ const getMonthOptions = (): MonthOption[] => {
|
|||||||
export default function BillingCredits() {
|
export default function BillingCredits() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const session = useSession();
|
||||||
|
const orgBranding = useOrgBranding();
|
||||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
@@ -66,9 +70,28 @@ export default function BillingCredits() {
|
|||||||
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
||||||
|
|
||||||
const params = useParamsWithFallback();
|
const params = useParamsWithFallback();
|
||||||
const teamId = params.id ? Number(params.id) : undefined;
|
const orgId = session.data?.user?.org?.id;
|
||||||
|
|
||||||
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
|
const parsedTeamId = Number(params.id);
|
||||||
|
const teamId: number | undefined = Number.isFinite(parsedTeamId)
|
||||||
|
? parsedTeamId
|
||||||
|
: typeof orgId === "number"
|
||||||
|
? orgId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const tokens = (pathname ?? "").split("/").filter(Boolean);
|
||||||
|
const settingsIndex = tokens.indexOf("settings");
|
||||||
|
const isOrgScopedPath =
|
||||||
|
settingsIndex >= 0 && ["organizations", "teams"].includes(tokens[settingsIndex + 1]);
|
||||||
|
|
||||||
|
const shouldRender = IS_SMS_CREDITS_ENABLED && !(orgId && !isOrgScopedPath && !orgBranding?.slug);
|
||||||
|
|
||||||
|
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
|
||||||
|
{ teamId },
|
||||||
|
{ enabled: shouldRender }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldRender) return null;
|
||||||
|
|
||||||
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -102,10 +125,6 @@ export default function BillingCredits() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!IS_SMS_CREDITS_ENABLED) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading && teamId) return <BillingCreditsSkeleton />;
|
if (isLoading && teamId) return <BillingCreditsSkeleton />;
|
||||||
if (!creditsData) return null;
|
if (!creditsData) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
|
|||||||
},
|
},
|
||||||
weekStart: {
|
weekStart: {
|
||||||
value: user.weekStart,
|
value: user.weekStart,
|
||||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
label: weekStartOptions.find((option) => option.value === user.weekStart)?.label || "",
|
||||||
},
|
},
|
||||||
travelSchedules:
|
travelSchedules:
|
||||||
travelSchedules.map((schedule) => {
|
travelSchedules.map((schedule) => {
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export default function Signup({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
|
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||||
localStorage.setItem("onBoardingRedirect", redirectUrl);
|
localStorage.setItem("onBoardingRedirect", redirectUrl);
|
||||||
}
|
}
|
||||||
}, [redirectUrl]);
|
}, [redirectUrl]);
|
||||||
@@ -216,7 +217,6 @@ export default function Signup({
|
|||||||
if (err.checkoutSessionId) {
|
if (err.checkoutSessionId) {
|
||||||
const stripe = await getStripe();
|
const stripe = await getStripe();
|
||||||
if (stripe) {
|
if (stripe) {
|
||||||
console.log("Redirecting to stripe checkout");
|
|
||||||
const { error } = await stripe.redirectToCheckout({
|
const { error } = await stripe.redirectToCheckout({
|
||||||
sessionId: err.checkoutSessionId,
|
sessionId: err.checkoutSessionId,
|
||||||
});
|
});
|
||||||
@@ -470,6 +470,7 @@ export default function Signup({
|
|||||||
showToast("error", t("username_required"));
|
showToast("error", t("username_required"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||||
localStorage.setItem("username", username);
|
localStorage.setItem("username", username);
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
// @NOTE: don't remove username query param as it's required right now for stripe payment page
|
// @NOTE: don't remove username query param as it's required right now for stripe payment page
|
||||||
@@ -539,6 +540,7 @@ export default function Signup({
|
|||||||
if (prepopulateFormValues?.username) {
|
if (prepopulateFormValues?.username) {
|
||||||
// If username is present we save it in query params to check for premium
|
// If username is present we save it in query params to check for premium
|
||||||
searchQueryParams.set("username", prepopulateFormValues.username);
|
searchQueryParams.set("username", prepopulateFormValues.username);
|
||||||
|
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||||
localStorage.setItem("username", prepopulateFormValues.username);
|
localStorage.setItem("username", prepopulateFormValues.username);
|
||||||
}
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ vi.mock("next/navigation", () => ({
|
|||||||
useRouter: vi.fn().mockReturnValue({
|
useRouter: vi.fn().mockReturnValue({
|
||||||
replace: vi.fn(),
|
replace: vi.fn(),
|
||||||
}),
|
}),
|
||||||
usePathname: vi.fn(),
|
usePathname: vi.fn().mockReturnValue("/settings/billing"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/app-store/BookingPageTagManager", () => ({
|
vi.mock("@calcom/app-store/BookingPageTagManager", () => ({
|
||||||
|
|||||||
+26
-2
@@ -196,6 +196,8 @@ const nextConfig = (phase) => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
||||||
optimizePackageImports: ["@calcom/ui"],
|
optimizePackageImports: ["@calcom/ui"],
|
||||||
|
webpackMemoryOptimizations: true,
|
||||||
|
webpackBuildWorker: true,
|
||||||
},
|
},
|
||||||
productionBrowserSourceMaps: true,
|
productionBrowserSourceMaps: true,
|
||||||
/* We already do type check on GH actions */
|
/* We already do type check on GH actions */
|
||||||
@@ -231,7 +233,15 @@ const nextConfig = (phase) => {
|
|||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
webpack: (config, { webpack, buildId, isServer }) => {
|
webpack: (config, { webpack, buildId, isServer, dev }) => {
|
||||||
|
if (!dev) {
|
||||||
|
if (config.cache) {
|
||||||
|
config.cache = Object.freeze({
|
||||||
|
type: "memory",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isServer) {
|
if (isServer) {
|
||||||
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
|
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
|
||||||
config.plugins.push(
|
config.plugins.push(
|
||||||
@@ -285,6 +295,10 @@ const nextConfig = (phase) => {
|
|||||||
source: "/routing/:path*",
|
source: "/routing/:path*",
|
||||||
destination: "/apps/routing-forms/:path*",
|
destination: "/apps/routing-forms/:path*",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/routing-forms",
|
||||||
|
destination: "/apps/routing-forms/forms",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/success/:path*",
|
source: "/success/:path*",
|
||||||
has: [
|
has: [
|
||||||
@@ -538,7 +552,7 @@ const nextConfig = (phase) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/apps/routing-forms",
|
source: "/apps/routing-forms",
|
||||||
destination: "/routing/forms",
|
destination: "/apps/routing-forms/forms",
|
||||||
permanent: false,
|
permanent: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -546,6 +560,11 @@ const nextConfig = (phase) => {
|
|||||||
destination: "/app-store/:path*",
|
destination: "/app-store/:path*",
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/auth/new",
|
||||||
|
destination: process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/auth/signup",
|
source: "/auth/signup",
|
||||||
destination: "/signup",
|
destination: "/signup",
|
||||||
@@ -643,6 +662,11 @@ const nextConfig = (phase) => {
|
|||||||
destination: "/settings/platform",
|
destination: "/settings/platform",
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/settings/admin/apps",
|
||||||
|
destination: "/settings/admin/apps/calendar",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
||||||
...(process.env.NODE_ENV === "development" &&
|
...(process.env.NODE_ENV === "development" &&
|
||||||
// Safer to enable the redirect only when the user is opting to test out organizations
|
// Safer to enable the redirect only when the user is opting to test out organizations
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.6.4",
|
"version": "5.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@calcom/tsconfig": "*",
|
"@calcom/tsconfig": "*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "*",
|
||||||
"@daily-co/daily-js": "^0.76.0",
|
"@daily-co/daily-js": "^0.83.1",
|
||||||
"@daily-co/daily-react": "^0.22.0",
|
"@daily-co/daily-react": "^0.23.2",
|
||||||
"@dub/analytics": "^0.0.27",
|
"@dub/analytics": "^0.0.27",
|
||||||
"@dub/embed-core": "^0.0.10",
|
"@dub/embed-core": "^0.0.10",
|
||||||
"@dub/embed-react": "^0.0.10",
|
"@dub/embed-react": "^0.0.10",
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.35.0",
|
||||||
"@tanstack/react-query": "^5.17.15",
|
"@tanstack/react-query": "^5.17.15",
|
||||||
"@team-plain/typescript-sdk": "^5.9.0",
|
"@team-plain/typescript-sdk": "^5.9.0",
|
||||||
|
"@todesktop/client-core": "^1.20.0",
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
"@upstash/redis": "^1.35.2",
|
"@upstash/redis": "^1.35.2",
|
||||||
"@vercel/edge-config": "^0.1.1",
|
"@vercel/edge-config": "^0.1.1",
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
"next-themes": "^0.2.0",
|
"next-themes": "^0.2.0",
|
||||||
"nodemailer": "^6.7.8",
|
"nodemailer": "^6.7.8",
|
||||||
"nuqs": "^1.20.0",
|
"nuqs": "^1.20.0",
|
||||||
|
"openid-client": "6.5.0",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"posthog-js": "^1.164.1",
|
"posthog-js": "^1.164.1",
|
||||||
"posthog-node": "^4.2.0",
|
"posthog-node": "^4.2.0",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const applyTheme = function () {
|
|||||||
export const applyToDesktopClass = function () {
|
export const applyToDesktopClass = function () {
|
||||||
try {
|
try {
|
||||||
const onReady = () => {
|
const onReady = () => {
|
||||||
if (typeof window !== "undefined" && window.todesktop && document.documentElement) {
|
if (typeof window !== "undefined" && window.calIsDesktopApp && document.documentElement) {
|
||||||
document.documentElement.classList.add("todesktop");
|
document.documentElement.classList.add("todesktop");
|
||||||
} else if (document.documentElement) {
|
} else if (document.documentElement) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { platform } from "@todesktop/client-core";
|
||||||
import type { IncomingMessage } from "http";
|
import type { IncomingMessage } from "http";
|
||||||
import { dir } from "i18next";
|
import { dir } from "i18next";
|
||||||
import type { DocumentContext, DocumentProps } from "next/document";
|
import type { DocumentContext, DocumentProps } from "next/document";
|
||||||
@@ -37,6 +38,14 @@ class MyDocument extends Document<Props> {
|
|||||||
const newLocale = this.props.newLocale || "en";
|
const newLocale = this.props.newLocale || "en";
|
||||||
const newDir = dir(newLocale);
|
const newDir = dir(newLocale);
|
||||||
|
|
||||||
|
const isDesktopApp = (() => {
|
||||||
|
try {
|
||||||
|
return platform.todesktop.isDesktopApp();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html
|
<Html
|
||||||
lang={newLocale}
|
lang={newLocale}
|
||||||
@@ -49,6 +58,7 @@ class MyDocument extends Document<Props> {
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
window.calNewLocale = "${newLocale}";
|
window.calNewLocale = "${newLocale}";
|
||||||
|
window.calIsDesktopApp = ${isDesktopApp};
|
||||||
(${applyTheme.toString()})();
|
(${applyTheme.toString()})();
|
||||||
(${applyToDesktopClass.toString()})();
|
(${applyToDesktopClass.toString()})();
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||||
|
import { aiVoiceAgentRouter } from "@calcom/trpc/server/routers/viewer/aiVoiceAgent/_router";
|
||||||
|
|
||||||
|
export default createNextApiHandler(aiVoiceAgentRouter);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||||
|
import { phoneNumberRouter } from "@calcom/trpc/server/routers/viewer/phoneNumber/_router";
|
||||||
|
|
||||||
|
export default createNextApiHandler(phoneNumberRouter);
|
||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
|
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
|
||||||
|
import { CreditUsageType } from "@calcom/prisma/enums";
|
||||||
import { IS_SMS_CREDITS_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
import { IS_SMS_CREDITS_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { getPublishedOrgIdFromMemberOrTeamId } from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
import { getPublishedOrgIdFromMemberOrTeamId } from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
@@ -101,6 +102,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
bookingUid: parsedBookingUid,
|
bookingUid: parsedBookingUid,
|
||||||
smsSid,
|
smsSid,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
|
creditFor: CreditUsageType.SMS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`);
|
return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`);
|
||||||
@@ -135,6 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
bookingUid: parsedBookingUid,
|
bookingUid: parsedBookingUid,
|
||||||
smsSid,
|
smsSid,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
|
creditFor: CreditUsageType.SMS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
|
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
|
||||||
@@ -151,6 +154,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
smsSid,
|
smsSid,
|
||||||
bookingUid: parsedBookingUid,
|
bookingUid: parsedBookingUid,
|
||||||
smsSegments: numSegments ?? undefined,
|
smsSegments: numSegments ?? undefined,
|
||||||
|
creditFor: CreditUsageType.SMS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (chargedUserOrTeamId) {
|
if (chargedUserOrTeamId) {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
import { createTeamEventType } from "./fixtures/users";
|
||||||
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
|
test.describe("Duplicate API Calls Prevention", () => {
|
||||||
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
|
test("should detect when schedule endpoints are called multiple times for individual user events", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const user = await users.create();
|
||||||
|
const eventType = user.eventTypes.find((e) => e.slug === "30-min");
|
||||||
|
|
||||||
|
const trpcCalls: string[] = [];
|
||||||
|
const apiV2Calls: string[] = [];
|
||||||
|
|
||||||
|
// Intercept tRPC getSchedule calls - pattern matches trpc.viewer.slots.getSchedule.useQuery()
|
||||||
|
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
|
||||||
|
trpcCalls.push(route.request().url());
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||||
|
apiV2Calls.push(route.request().url());
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/${user.username}/${eventType?.slug}`);
|
||||||
|
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const totalCalls = trpcCalls.length + apiV2Calls.length;
|
||||||
|
|
||||||
|
if (totalCalls === 0) {
|
||||||
|
console.log("No API calls detected - environment may have issues preventing page load");
|
||||||
|
expect(totalCalls).toBeGreaterThanOrEqual(0);
|
||||||
|
} else {
|
||||||
|
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||||
|
expect(trpcCalls.length).toBeLessThanOrEqual(1);
|
||||||
|
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should detect when schedule endpoints are called multiple times for team events", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const teamOwner = await users.create(
|
||||||
|
{ name: "Team Owner" },
|
||||||
|
{
|
||||||
|
hasTeam: true,
|
||||||
|
teammates: [{ name: "teammate-1" }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { team } = await teamOwner.getFirstTeamMembership();
|
||||||
|
const teamEvent = await createTeamEventType(
|
||||||
|
{ id: teamOwner.id },
|
||||||
|
{ id: team.id },
|
||||||
|
{ teamEventSlug: "team-event-test", teamEventTitle: "Team Event Test" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const trpcCalls: string[] = [];
|
||||||
|
const apiV2Calls: string[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
|
||||||
|
trpcCalls.push(route.request().url());
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||||
|
apiV2Calls.push(route.request().url());
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/team/${team.slug}/${teamEvent.slug}`);
|
||||||
|
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const totalCalls = trpcCalls.length + apiV2Calls.length;
|
||||||
|
|
||||||
|
if (totalCalls === 0) {
|
||||||
|
console.log("No API calls detected - environment may have issues preventing page load");
|
||||||
|
expect(totalCalls).toBeGreaterThanOrEqual(0);
|
||||||
|
} else {
|
||||||
|
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||||
|
expect(trpcCalls.length).toBeLessThanOrEqual(1);
|
||||||
|
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should detect when schedule endpoints are called multiple times for organization team events", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const orgOwner = await users.create(
|
||||||
|
{ name: "Org Owner" },
|
||||||
|
{
|
||||||
|
hasTeam: true,
|
||||||
|
teammates: [{ name: "teammate-1" }],
|
||||||
|
isOrg: true,
|
||||||
|
isOrgVerified: true,
|
||||||
|
hasSubteam: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { team } = await orgOwner.getFirstTeamMembership();
|
||||||
|
const { team: org } = await orgOwner.getOrgMembership();
|
||||||
|
const teamEvent = await createTeamEventType(
|
||||||
|
{ id: orgOwner.id },
|
||||||
|
{ id: team.id },
|
||||||
|
{ teamEventSlug: "org-team-event", teamEventTitle: "Org Team Event" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const trpcCalls: string[] = [];
|
||||||
|
const apiV2Calls: string[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
|
||||||
|
trpcCalls.push(route.request().url());
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||||
|
apiV2Calls.push(route.request().url());
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||||
|
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const totalCalls = trpcCalls.length + apiV2Calls.length;
|
||||||
|
|
||||||
|
if (totalCalls === 0) {
|
||||||
|
console.log("No API calls detected - environment may have issues preventing page load");
|
||||||
|
expect(totalCalls).toBeGreaterThanOrEqual(0);
|
||||||
|
} else {
|
||||||
|
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||||
|
expect(trpcCalls.length).toBeLessThanOrEqual(1);
|
||||||
|
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import prisma from "@calcom/prisma";
|
|||||||
import { BookingStatus } from "@calcom/prisma/client";
|
import { BookingStatus } from "@calcom/prisma/client";
|
||||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { addFilter, openFilter } from "./filter-helpers";
|
import { addFilter } from "./filter-helpers";
|
||||||
import { createTeamEventType } from "./fixtures/users";
|
import { createTeamEventType } from "./fixtures/users";
|
||||||
import type { Fixtures } from "./lib/fixtures";
|
import type { Fixtures } from "./lib/fixtures";
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
@@ -78,7 +78,6 @@ test.describe("Bookings", () => {
|
|||||||
await bookingsGetResponse;
|
await bookingsGetResponse;
|
||||||
|
|
||||||
await addFilter(page, "dateRange");
|
await addFilter(page, "dateRange");
|
||||||
await openFilter(page, "dateRange");
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
|
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
|
||||||
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
|
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
|
||||||
@@ -244,7 +243,6 @@ test.describe("Bookings", () => {
|
|||||||
await bookingsGetResponse;
|
await bookingsGetResponse;
|
||||||
|
|
||||||
await addFilter(page, "dateRange");
|
await addFilter(page, "dateRange");
|
||||||
await openFilter(page, "dateRange");
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
|
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
|
||||||
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
|
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
|
||||||
@@ -354,7 +352,6 @@ test.describe("Bookings", () => {
|
|||||||
await bookingsGetResponse;
|
await bookingsGetResponse;
|
||||||
|
|
||||||
await addFilter(page, "userId");
|
await addFilter(page, "userId");
|
||||||
await openFilter(page, "userId");
|
|
||||||
|
|
||||||
const bookingsGetResponse2 = page.waitForResponse(
|
const bookingsGetResponse2 = page.waitForResponse(
|
||||||
(response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200
|
(response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200
|
||||||
@@ -463,7 +460,6 @@ test.describe("Bookings", () => {
|
|||||||
await bookingsGetResponse1;
|
await bookingsGetResponse1;
|
||||||
|
|
||||||
await addFilter(page, "userId");
|
await addFilter(page, "userId");
|
||||||
await openFilter(page, "userId");
|
|
||||||
const bookingsGetResponse2 = page.waitForResponse((response) =>
|
const bookingsGetResponse2 = page.waitForResponse((response) =>
|
||||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user