Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8829e1ca3f | ||
|
|
ab703a4346 | ||
|
|
8d5a7b4e10 | ||
|
|
6ba3049314 | ||
|
|
abe92cd693 | ||
|
|
6c6cf0433a | ||
|
|
211b958c58 | ||
|
|
01a0d43943 | ||
|
|
3133eb5aeb | ||
|
|
76b68bebd7 | ||
|
|
c20e723e34 | ||
|
|
7b7478c0a9 | ||
|
+3 |
ebeb008f9b | ||
|
|
a90d765fca | ||
|
|
af51df2b4d | ||
|
|
076e93ca56 | ||
|
|
47f45409d2 | ||
|
|
759d5b8663 | ||
|
|
c554396572 | ||
|
|
fafd3fb181 | ||
|
|
eb05d547de | ||
|
|
1fa8a5ef25 | ||
|
|
5a350efb40 | ||
|
|
242f86891a | ||
|
|
ce6969d058 | ||
|
|
26f8246b91 | ||
|
|
4baa9b6bc1 | ||
|
|
02c86d0268 | ||
|
|
49f99d69a8 | ||
|
|
659d9cf767 | ||
|
|
4d662d31a1 | ||
|
|
6fe6540bc6 | ||
|
|
ee1904bb2c | ||
|
|
aaa59f1455 | ||
|
|
e29b7e83b8 | ||
|
|
6af473a486 | ||
|
|
bf8f966811 | ||
|
|
c64b663514 | ||
|
|
29314cde75 | ||
|
|
3ea73e75d6 | ||
|
|
d18e23378b | ||
|
|
736f1f594c | ||
|
|
09b1643478 | ||
|
|
86f6e8bac3 | ||
|
|
9c65d02e84 | ||
|
|
be9ab02a7e | ||
|
|
cfefba6d4d | ||
|
|
815e13303b | ||
|
|
7c8dbb3ea3 | ||
|
|
cabd459682 | ||
|
|
33aedbae20 | ||
|
|
d3fbc73a39 | ||
|
|
cfda4757e4 | ||
|
|
895ed48bd6 | ||
|
|
eda328d196 | ||
|
|
1c501d1501 | ||
|
|
08a57f32c5 | ||
|
|
b5a26bc227 | ||
|
|
737b019199 | ||
|
|
b41031626f | ||
|
|
a1fc4bccb9 | ||
|
|
d764b6a32d | ||
|
|
87cb8b170a | ||
|
|
badd0de33c | ||
|
|
124fd8ebca | ||
|
|
5d0dc3f546 | ||
|
|
e4baf15b53 | ||
|
|
a1bd8b6259 | ||
|
|
7987c9f45d | ||
|
|
781d0d92df | ||
|
|
b71d8baccc | ||
|
|
a71d949a0c | ||
|
|
e55cee443b | ||
|
|
c3634b3aba | ||
|
|
cd576f5378 | ||
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
|
||||
|
||||
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
|
||||
|
||||
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: style calendar settings and availability overrides
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
booker atom: allow toggling org and team info when booking round robin
|
||||
+5
-1
@@ -1 +1,5 @@
|
||||
module.exports = require("./packages/config/eslint-preset");
|
||||
// This configuration only applies to the package manager root.
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["./packages/config/eslint-preset.js"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Atoms E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/platform/atoms/**'
|
||||
- 'packages/platform/examples/base/**'
|
||||
- 'apps/api/v2/**'
|
||||
- '.github/workflows/atoms-e2e.yml'
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8096
|
||||
ATOMS_E2E_OAUTH_CLIENT_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID }}
|
||||
ATOMS_E2E_OAUTH_CLIENT_SECRET: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET }}
|
||||
ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
|
||||
ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
|
||||
ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
|
||||
jobs:
|
||||
atoms-e2e:
|
||||
timeout-minutes: 15
|
||||
name: Atoms E2E Tests
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- name: Run Atoms E2E Tests
|
||||
working-directory: packages/platform/examples/base
|
||||
run: yarn test:e2e
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: atoms-e2e-test-results
|
||||
path: test-results
|
||||
retention-days: 7
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: atoms-e2e-playwright-report
|
||||
path: playwright-report
|
||||
retention-days: 7
|
||||
@@ -22,6 +22,7 @@ env:
|
||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
|
||||
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
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -191,8 +191,9 @@ const availabilitySchema = z
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { isSystemWideAdmin, userId: reqUserId } = req;
|
||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||
const userAvailabilityService = getUserAvailabilityService();
|
||||
if (!teamId)
|
||||
return getUserAvailability({
|
||||
return userAvailabilityService.getUserAvailability({
|
||||
username,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
|
||||
const availabilities = members.map(async (user) => {
|
||||
return {
|
||||
userId: user.id,
|
||||
availability: await getUserAvailability({
|
||||
availability: await userAvailabilityService.getUserAvailability({
|
||||
userId: user.id,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
|
||||
@@ -76,11 +76,13 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
|
||||
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials", () => ({
|
||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||
qualifiedRRHosts: [],
|
||||
allFallbackRRHosts: [],
|
||||
fixedHosts: [],
|
||||
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
||||
getQualifiedHostsService: vi.fn().mockReturnValue({
|
||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||
qualifiedRRHosts: [],
|
||||
allFallbackRRHosts: [],
|
||||
fixedHosts: [],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -42,15 +42,17 @@ $ yarn prisma generate
|
||||
|
||||
Copy `.env.example` to `.env` and fill values.
|
||||
|
||||
## Add license Key to deployments table in DB
|
||||
## Add license Key to Deployment table in DB
|
||||
|
||||
id, logo theme licenseKey agreedLicenseAt
|
||||
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
||||
id, logo, theme, licenseKey, agreedLicenseAt:-
|
||||
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
|
||||
|
||||
Replace with your actual license key.
|
||||
|
||||
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||
|
||||
.env
|
||||
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
|
||||
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
|
||||
|
||||
## Running the app
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
@@ -98,6 +98,8 @@
|
||||
"@types/luxon": "^3.3.7",
|
||||
"@types/passport-jwt": "^3.0.13",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6",
|
||||
"@typescript-eslint/parser": "^6",
|
||||
"jest": "^29.7.0",
|
||||
"jest-date-mock": "^1.0.10",
|
||||
"node-mocks-http": "^1.16.2",
|
||||
|
||||
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled
|
||||
? rescheduledToInfo?.rescheduledBy
|
||||
: databaseBooking.rescheduledBy;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||
return {
|
||||
uid: rescheduledTo?.uid,
|
||||
rescheduledBy: rescheduledTo?.rescheduledBy
|
||||
return {
|
||||
uid: rescheduledTo?.uid,
|
||||
rescheduledBy: rescheduledTo?.rescheduledBy,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||
if (databaseMetadata === null) return {};
|
||||
|
||||
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled
|
||||
? rescheduledToInfo?.rescheduledBy
|
||||
: databaseBooking.rescheduledBy;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { CreatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
describe("Event Types Private Links Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: any;
|
||||
let organization: any;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let user: any;
|
||||
let eventType: any;
|
||||
|
||||
const userEmail = `private-links-user-${randomString()}@api.com`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `private-links-organization-${randomString()}`,
|
||||
slug: `private-links-org-slug-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
name: `private-links-user-${randomString()}`,
|
||||
username: `private-links-user-${randomString()}`,
|
||||
});
|
||||
|
||||
// create an event type owned by user
|
||||
eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `private-links-event-type-${randomString()}`,
|
||||
slug: `private-links-event-type-${randomString()}`,
|
||||
length: 30,
|
||||
locations: [],
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["redirect-uri"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("POST /v2/event-types/:eventTypeId/private-links - create private link", async () => {
|
||||
const body: CreatePrivateLinkInput = {
|
||||
expiresAt: undefined,
|
||||
maxUsageCount: 5,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||
expect(response.body.data.linkId).toBeDefined();
|
||||
expect(response.body.data.maxUsageCount).toBe(5);
|
||||
expect(response.body.data.usageCount).toBeDefined();
|
||||
});
|
||||
|
||||
it("GET /v2/event-types/:eventTypeId/private-links - list private links", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("PATCH /v2/event-types/:eventTypeId/private-links/:linkId - update private link", async () => {
|
||||
// create a link first
|
||||
const createResp = await request(app.getHttpServer())
|
||||
.post(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.send({ maxUsageCount: 3 })
|
||||
.expect(201);
|
||||
|
||||
const linkId = createResp.body.data.linkId;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.send({ maxUsageCount: 10 })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||
expect(response.body.data.maxUsageCount).toBe(10);
|
||||
});
|
||||
|
||||
it("DELETE /v2/event-types/:eventTypeId/private-links/:linkId - delete private link", async () => {
|
||||
// create a link to delete
|
||||
const createResp = await request(app.getHttpServer())
|
||||
.post(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.send({ maxUsageCount: 2 })
|
||||
.expect(201);
|
||||
|
||||
const linkId = createResp.body.data.linkId;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.delete(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||
expect(response.body.data.linkId).toBe(linkId);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// cleanup created entities
|
||||
try {
|
||||
if (eventType?.id) {
|
||||
const repo = new EventTypesRepositoryFixture((app as any).select(AppModule));
|
||||
await repo.delete(eventType.id);
|
||||
}
|
||||
} catch {}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
EVENT_TYPE_READ,
|
||||
EVENT_TYPE_WRITE,
|
||||
SUCCESS_STATUS,
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
CreatePrivateLinkInput,
|
||||
CreatePrivateLinkOutput,
|
||||
DeletePrivateLinkOutput,
|
||||
GetPrivateLinksOutput,
|
||||
UpdatePrivateLinkInput,
|
||||
UpdatePrivateLinkOutput,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
import { PrivateLinksService } from "../services/private-links.service";
|
||||
|
||||
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
|
||||
|
||||
@Controller({
|
||||
path: "/v2/event-types/:eventTypeId/private-links",
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event Types Private Links")
|
||||
export class EventTypesPrivateLinksController {
|
||||
constructor(private readonly privateLinksService: PrivateLinksService) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Create a private link for an event type" })
|
||||
async createPrivateLink(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: CreatePrivateLinkInput,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<CreatePrivateLinkOutput> {
|
||||
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, userId, body);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: privateLink,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get all private links for an event type" })
|
||||
async getPrivateLinks(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<GetPrivateLinksOutput> {
|
||||
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: privateLinks,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:linkId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Update a private link for an event type" })
|
||||
async updatePrivateLink(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Param("linkId") linkId: string,
|
||||
@Body() body: UpdatePrivateLinkBody,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<UpdatePrivateLinkOutput> {
|
||||
const updateInput = { ...body, linkId };
|
||||
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: privateLink,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:linkId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Delete a private link for an event type" })
|
||||
async deletePrivateLink(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Param("linkId") linkId: string,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<DeletePrivateLinkOutput> {
|
||||
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
linkId,
|
||||
message: "Private link deleted successfully",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||
|
||||
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
|
||||
import { PrivateLinksInputService } from "./services/private-links-input.service";
|
||||
import { PrivateLinksOutputService } from "./services/private-links-output.service";
|
||||
import { PrivateLinksService } from "./services/private-links.service";
|
||||
import { PrivateLinksRepository } from "./private-links.repository";
|
||||
|
||||
@Module({
|
||||
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
|
||||
controllers: [EventTypesPrivateLinksController],
|
||||
providers: [
|
||||
PrivateLinksService,
|
||||
PrivateLinksInputService,
|
||||
PrivateLinksOutputService,
|
||||
PrivateLinksRepository,
|
||||
EventTypeOwnershipGuard,
|
||||
],
|
||||
})
|
||||
export class EventTypesPrivateLinksModule {}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async listByEventTypeId(eventTypeId: number) {
|
||||
return this.dbRead.prisma.hashedLink.findMany({
|
||||
where: { eventTypeId },
|
||||
select: { link: true, expiresAt: true, maxUsageCount: true, usageCount: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findWithEventTypeDetails(linkId: string) {
|
||||
return this.dbRead.prisma.hashedLink.findUnique({
|
||||
where: { link: linkId },
|
||||
select: {
|
||||
link: true,
|
||||
expiresAt: true,
|
||||
maxUsageCount: true,
|
||||
usageCount: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
|
||||
return this.dbWrite.prisma.hashedLink.create({
|
||||
data: {
|
||||
eventTypeId,
|
||||
link: link.link,
|
||||
expiresAt: link.expiresAt,
|
||||
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
|
||||
return this.dbWrite.prisma.hashedLink.updateMany({
|
||||
where: { eventTypeId, link: link.link },
|
||||
data: {
|
||||
expiresAt: link.expiresAt,
|
||||
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(eventTypeId: number, linkId: string) {
|
||||
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CreatePrivateLinkInput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksInputService {
|
||||
constructor() {}
|
||||
|
||||
transformCreateInput(input: CreatePrivateLinkInput): CreatePrivateLinkInput {
|
||||
const hasExpires = input.expiresAt !== undefined && input.expiresAt !== null;
|
||||
const hasMaxCount = typeof input.maxUsageCount === "number";
|
||||
|
||||
if (!hasExpires && !hasMaxCount) {
|
||||
throw new Error("Either expiresAt or maxUsageCount must be provided");
|
||||
}
|
||||
|
||||
if (hasExpires && hasMaxCount) {
|
||||
throw new Error("Provide only one of expiresAt or maxUsageCount");
|
||||
}
|
||||
|
||||
return {
|
||||
expiresAt: input.expiresAt,
|
||||
maxUsageCount: input.maxUsageCount ?? (hasMaxCount ? input.maxUsageCount : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
transformUpdateInput(input: UpdatePrivateLinkInput): UpdatePrivateLinkInput {
|
||||
return {
|
||||
linkId: input.linkId,
|
||||
expiresAt: input.expiresAt,
|
||||
maxUsageCount: input.maxUsageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
export type PrivateLinkData = {
|
||||
id: string;
|
||||
eventTypeId: number;
|
||||
isExpired: boolean;
|
||||
bookingUrl: string;
|
||||
expiresAt?: Date | null;
|
||||
maxUsageCount?: number | null;
|
||||
usageCount?: number;
|
||||
};
|
||||
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksOutputService {
|
||||
constructor() {}
|
||||
|
||||
transformToOutput(data: PrivateLinkData): PrivateLinkOutput {
|
||||
const baseData = {
|
||||
linkId: data.id.toString(),
|
||||
eventTypeId: data.eventTypeId,
|
||||
isExpired: data.isExpired,
|
||||
bookingUrl: data.bookingUrl,
|
||||
};
|
||||
|
||||
if (data.expiresAt !== null && data.expiresAt !== undefined) {
|
||||
return plainToClass(TimeBasedPrivateLinkOutput, { ...baseData, expiresAt: data.expiresAt });
|
||||
}
|
||||
|
||||
return plainToClass(UsageBasedPrivateLinkOutput, {
|
||||
...baseData,
|
||||
maxUsageCount: data.maxUsageCount || 0,
|
||||
usageCount: data.usageCount || 0,
|
||||
});
|
||||
}
|
||||
|
||||
transformArrayToOutput(data: PrivateLinkData[]): PrivateLinkOutput[] {
|
||||
return data.map((item) => this.transformToOutput(item));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
|
||||
import {
|
||||
PrivateLinksOutputService,
|
||||
type PrivateLinkData,
|
||||
} from "@/ee/event-types-private-links/services/private-links-output.service";
|
||||
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksService {
|
||||
constructor(
|
||||
private readonly inputService: PrivateLinksInputService,
|
||||
private readonly outputService: PrivateLinksOutputService,
|
||||
private readonly repo: PrivateLinksRepository
|
||||
) {}
|
||||
|
||||
async createPrivateLink(
|
||||
eventTypeId: number,
|
||||
userId: number,
|
||||
input: CreatePrivateLinkInput
|
||||
): Promise<PrivateLinkOutput> {
|
||||
try {
|
||||
const transformedInput = this.inputService.transformCreateInput(input);
|
||||
const created = await this.repo.create(eventTypeId, {
|
||||
link: generateHashedLink(userId),
|
||||
expiresAt: transformedInput.expiresAt ?? null,
|
||||
maxUsageCount: transformedInput.maxUsageCount ?? null,
|
||||
});
|
||||
const mapped: PrivateLinkData = {
|
||||
id: created.link,
|
||||
eventTypeId,
|
||||
isExpired: isLinkExpired(created as any),
|
||||
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${created.link}`,
|
||||
expiresAt: created.expiresAt ?? null,
|
||||
maxUsageCount: (created as any).maxUsageCount ?? null,
|
||||
usageCount: (created as any).usageCount ?? 0,
|
||||
};
|
||||
return this.outputService.transformToOutput(mapped);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
throw new BadRequestException("Failed to create private link");
|
||||
}
|
||||
}
|
||||
|
||||
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
|
||||
try {
|
||||
const links = await this.repo.listByEventTypeId(eventTypeId);
|
||||
const mapped: PrivateLinkData[] = links.map((l) => ({
|
||||
id: l.link,
|
||||
eventTypeId,
|
||||
isExpired: isLinkExpired(l as any),
|
||||
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${l.link}`,
|
||||
expiresAt: l.expiresAt ?? null,
|
||||
maxUsageCount: l.maxUsageCount ?? null,
|
||||
usageCount: l.usageCount ?? 0,
|
||||
}));
|
||||
return this.outputService.transformArrayToOutput(mapped);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
throw new BadRequestException("Failed to get private links");
|
||||
}
|
||||
}
|
||||
|
||||
async updatePrivateLink(
|
||||
eventTypeId: number,
|
||||
userId: number,
|
||||
input: UpdatePrivateLinkInput
|
||||
): Promise<PrivateLinkOutput> {
|
||||
try {
|
||||
const transformedInput = this.inputService.transformUpdateInput(input);
|
||||
const updatedResult = await this.repo.update(eventTypeId, {
|
||||
link: transformedInput.linkId,
|
||||
expiresAt: transformedInput.expiresAt ?? null,
|
||||
maxUsageCount: transformedInput.maxUsageCount ?? null,
|
||||
});
|
||||
if (!updatedResult || (updatedResult as any).count === 0) {
|
||||
throw new NotFoundException("Updated link not found");
|
||||
}
|
||||
const updated = await this.repo.findWithEventTypeDetails(transformedInput.linkId);
|
||||
if (!updated) throw new NotFoundException("Updated link not found");
|
||||
const mapped: PrivateLinkData = {
|
||||
id: updated.link,
|
||||
eventTypeId,
|
||||
isExpired: isLinkExpired(updated as any),
|
||||
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${updated.link}`,
|
||||
expiresAt: updated.expiresAt ?? null,
|
||||
maxUsageCount: updated.maxUsageCount ?? null,
|
||||
usageCount: updated.usageCount ?? 0,
|
||||
};
|
||||
return this.outputService.transformToOutput(mapped);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("not found")) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
throw new BadRequestException("Failed to update private link");
|
||||
}
|
||||
}
|
||||
|
||||
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
|
||||
try {
|
||||
const { count } = await this.repo.delete(eventTypeId, linkId);
|
||||
if (count === 0) {
|
||||
throw new NotFoundException("Deleted link not found");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("not found")) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
throw new BadRequestException("Failed to delete private link");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBookingFields).toBeDefined();
|
||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
||||
const fields = responseBookingField
|
||||
//@ts-ignore
|
||||
delete fields.labelAsSafeHtml
|
||||
expect(fields).toEqual(bookingFields[0]);
|
||||
eventType.bookingFields = responseBookingFields;
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
|
||||
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -35,6 +36,7 @@ import { Module } from "@nestjs/common";
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsModule,
|
||||
RoutingFormsModule,
|
||||
EventTypesPrivateLinksModule,
|
||||
],
|
||||
})
|
||||
export class PlatformEndpointsModule implements NestModule {
|
||||
|
||||
@@ -8,9 +8,14 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
@@ -24,10 +29,15 @@ import { Module } from "@nestjs/common";
|
||||
PrismaEventTypeRepository,
|
||||
PrismaRoutingFormResponseRepository,
|
||||
PrismaTeamRepository,
|
||||
RedisService,
|
||||
PrismaFeaturesRepository,
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
AvailableSlotsService,
|
||||
UserAvailabilityService,
|
||||
BusyTimesService,
|
||||
FilterHostsService,
|
||||
QualifiedHostsService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
|
||||
@@ -7,12 +7,17 @@ import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.rep
|
||||
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
import { UserAvailabilityService } from "./user-availability.service";
|
||||
|
||||
@Injectable()
|
||||
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
constructor(
|
||||
@@ -24,7 +29,13 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
userRepository: PrismaUserRepository,
|
||||
featuresRepository: PrismaFeaturesRepository
|
||||
redisService: RedisService,
|
||||
featuresRepository: PrismaFeaturesRepository,
|
||||
qualifiedHostsService: QualifiedHostsService,
|
||||
checkBookingLimitsService: CheckBookingLimitsService,
|
||||
cacheService: CacheService,
|
||||
userAvailabilityService: UserAvailabilityService,
|
||||
busyTimesService: BusyTimesService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -35,8 +46,13 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
selectedSlotRepo: selectedSlotRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
||||
cacheService: new CacheService(featuresRepository),
|
||||
redisClient: redisService,
|
||||
checkBookingLimitsService,
|
||||
cacheService,
|
||||
userAvailabilityService,
|
||||
busyTimesService,
|
||||
qualifiedHostsService,
|
||||
featuresRepo: featuresRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class BusyTimesService extends BaseBusyTimesService {
|
||||
constructor(bookingRepository: PrismaBookingRepository) {
|
||||
super({
|
||||
bookingRepo: bookingRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { FilterHostsService as BaseFilterHostsService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class FilterHostsService extends BaseFilterHostsService {
|
||||
constructor(bookingRepository: PrismaBookingRepository) {
|
||||
super({
|
||||
bookingRepo: bookingRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { QualifiedHostsService as BaseQualifiedHostsService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class QualifiedHostsService extends BaseQualifiedHostsService {
|
||||
constructor(bookingRepository: PrismaBookingRepository, filterHostsService: FilterHostsService) {
|
||||
super({
|
||||
bookingRepo: bookingRepository,
|
||||
filterHostsService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
|
||||
|
||||
@Injectable()
|
||||
export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||
constructor(
|
||||
oooRepoDependency: PrismaOOORepository,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
redisService: RedisService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
bookingRepo: bookingRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
redisClient: redisService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
|
||||
fromReschedule?: string | null;
|
||||
}
|
||||
) {
|
||||
|
||||
if (this.configService.get("e2e")) {
|
||||
return true;
|
||||
}
|
||||
const { uid, startTime, fromReschedule } = booking;
|
||||
|
||||
const delay = startTime.getTime() - Date.now();
|
||||
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
|
||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||
*/
|
||||
async cancelUsageByBookingUid(bookingUid: string) {
|
||||
if (this.configService.get("e2e")) {
|
||||
return true;
|
||||
}
|
||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
|
||||
await this.billingQueue.close();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypeOwnershipGuard implements CanActivate {
|
||||
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as ApiAuthGuardUser | undefined;
|
||||
const eventTypeIdParam = request.params?.eventTypeId;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException("EventTypeOwnershipGuard - No user associated with the request.");
|
||||
}
|
||||
|
||||
if (!eventTypeIdParam) {
|
||||
throw new BadRequestException("Missing eventTypeId param.");
|
||||
}
|
||||
|
||||
const eventTypeId = Number(eventTypeIdParam);
|
||||
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
|
||||
throw new BadRequestException("Invalid eventTypeId param.");
|
||||
}
|
||||
const eventType = await this.eventTypesService.getUserEventType(user.id, eventTypeId);
|
||||
if (!eventType) {
|
||||
// Mirrors EventTypesService behavior: NotFound when not owned or not present
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
public redis: Redis;
|
||||
private readonly logger = new Logger("RedisService");
|
||||
private isReady = false; // Track connection status
|
||||
|
||||
constructor(readonly configService: ConfigService<AppConfig>) {
|
||||
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
||||
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
||||
|
||||
this.redis = new Redis(dbUrl);
|
||||
|
||||
this.redis.on("error", (err) => {
|
||||
this.logger.error(`IoRedis connection error: ${err.message}`);
|
||||
this.isReady = false;
|
||||
});
|
||||
|
||||
this.redis.on("connect", () => {
|
||||
this.logger.log("IoRedis connected!");
|
||||
this.isReady = true;
|
||||
});
|
||||
|
||||
this.redis.on("reconnecting", (delay: string) => {
|
||||
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
|
||||
});
|
||||
|
||||
this.redis.on("end", () => {
|
||||
this.logger.warn("IoRedis connection ended.");
|
||||
this.isReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async get<TData>(key: string): Promise<TData | null> {
|
||||
let data = null;
|
||||
if (!this.isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
data = await this.redis.get(key);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as TData;
|
||||
} catch (e) {
|
||||
return data as TData;
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
if (!this.isReady) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return this.redis.del(key);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
|
||||
if (!this.isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
if (opts?.ttl) {
|
||||
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
|
||||
} else {
|
||||
await this.redis.set(key, stringifiedValue);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return "OK";
|
||||
}
|
||||
|
||||
async expire(key: string, seconds: number): Promise<0 | 1> {
|
||||
if (!this.isReady) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return this.redis.expire(key, seconds) as Promise<0 | 1>;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
|
||||
if (!this.isReady) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const results = await this.redis.lrange(key, start, end);
|
||||
return results.map((item) => JSON.parse(item) as TResult);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
|
||||
if (!this.isReady) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const stringifiedElements = elements.map((element) =>
|
||||
typeof element === "object" ? JSON.stringify(element) : String(element)
|
||||
);
|
||||
return this.redis.lpush(key, ...stringifiedElements);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SMS_ATTENDEE = "sms_attendee";
|
||||
export const SMS_NUMBER = "sms_number";
|
||||
export const WHATSAPP_ATTENDEE = "whatsapp_attendee";
|
||||
export const WHATSAPP_NUMBER = "whatsapp_number";
|
||||
export const CAL_AI_PHONE_CALL = "cal_ai_phone_call";
|
||||
|
||||
export const STEP_ACTIONS = [
|
||||
EMAIL_HOST,
|
||||
@@ -19,6 +20,7 @@ export const STEP_ACTIONS = [
|
||||
SMS_NUMBER,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
CAL_AI_PHONE_CALL,
|
||||
] as const;
|
||||
|
||||
export const STEP_ACTIONS_TO_ENUM = {
|
||||
@@ -29,6 +31,7 @@ export const STEP_ACTIONS_TO_ENUM = {
|
||||
[WHATSAPP_ATTENDEE]: WorkflowActions.WHATSAPP_ATTENDEE,
|
||||
[WHATSAPP_NUMBER]: WorkflowActions.WHATSAPP_NUMBER,
|
||||
[SMS_NUMBER]: WorkflowActions.SMS_NUMBER,
|
||||
[CAL_AI_PHONE_CALL]: WorkflowActions.CAL_AI_PHONE_CALL,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_STEP_ACTIONS = {
|
||||
@@ -39,6 +42,7 @@ export const ENUM_TO_STEP_ACTIONS = {
|
||||
[WorkflowActions.WHATSAPP_ATTENDEE]: WHATSAPP_ATTENDEE,
|
||||
[WorkflowActions.WHATSAPP_NUMBER]: WHATSAPP_NUMBER,
|
||||
[WorkflowActions.SMS_NUMBER]: SMS_NUMBER,
|
||||
[WorkflowActions.CAL_AI_PHONE_CALL]: CAL_AI_PHONE_CALL,
|
||||
} as const;
|
||||
|
||||
export type StepAction = (typeof STEP_ACTIONS)[number];
|
||||
|
||||
@@ -9893,6 +9893,198 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/event-types/{eventTypeId}/private-links": {
|
||||
"post": {
|
||||
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
|
||||
"summary": "Create a private link for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePrivateLinkInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
|
||||
"summary": "Get all private links for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetPrivateLinksOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
|
||||
"patch": {
|
||||
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
|
||||
"summary": "Update a private link for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linkId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdatePrivateLinkBody"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
|
||||
"summary": "Delete a private link for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linkId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/organizations": {
|
||||
"post": {
|
||||
"operationId": "OrganizationsOrganizationsController_createOrganization",
|
||||
@@ -21856,7 +22048,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
]
|
||||
},
|
||||
"recipient": {
|
||||
@@ -22218,7 +22411,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_address",
|
||||
"description": "Action to perform, send an email to a specific email address",
|
||||
@@ -22303,7 +22497,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_attendee",
|
||||
"description": "Action to perform, send an email to the attendees of the event",
|
||||
@@ -22379,7 +22574,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_host",
|
||||
"description": "Action to perform, send an email to the host of the event",
|
||||
@@ -22474,7 +22670,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -22543,7 +22740,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_number",
|
||||
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
|
||||
@@ -22621,7 +22819,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_number",
|
||||
"description": "Action to perform, send a text message to a specific phone number",
|
||||
@@ -22699,7 +22898,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_attendee",
|
||||
"description": "Action to perform, send a text message to the phone numbers of the attendees",
|
||||
@@ -22888,7 +23088,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_address",
|
||||
"description": "Action to perform, send an email to a specific email address",
|
||||
@@ -22978,7 +23179,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_attendee",
|
||||
"description": "Action to perform, send an email to the attendees of the event",
|
||||
@@ -23059,7 +23261,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_host",
|
||||
"description": "Action to perform, send an email to the host of the event",
|
||||
@@ -23140,7 +23343,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_attendee",
|
||||
"description": "Action to perform, send a text message to the phone numbers of the attendees",
|
||||
@@ -23218,7 +23422,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_number",
|
||||
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
|
||||
@@ -23301,7 +23506,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -23375,7 +23581,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_number",
|
||||
"description": "Action to perform, send a text message to a specific phone number",
|
||||
@@ -28012,6 +28219,230 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreatePrivateLinkInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"description": "Expiration date for time-based links",
|
||||
"format": "date-time",
|
||||
"example": "2024-12-31T23:59:59.000Z"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
|
||||
"example": 10,
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"TimeBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"description": "The private link ID",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event type ID this link belongs to",
|
||||
"example": 123
|
||||
},
|
||||
"isExpired": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the link is currently expired",
|
||||
"example": false
|
||||
},
|
||||
"bookingUrl": {
|
||||
"type": "string",
|
||||
"description": "Full booking URL for this private link",
|
||||
"format": "uri",
|
||||
"example": "https://cal.com/d/abc123def456/30min"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"description": "Expiration date for this time-based link",
|
||||
"format": "date-time",
|
||||
"example": "2025-12-31T23:59:59.000Z"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"linkId",
|
||||
"eventTypeId",
|
||||
"isExpired",
|
||||
"bookingUrl",
|
||||
"expiresAt"
|
||||
]
|
||||
},
|
||||
"UsageBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"description": "The private link ID",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event type ID this link belongs to",
|
||||
"example": 123
|
||||
},
|
||||
"isExpired": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the link is currently expired",
|
||||
"example": false
|
||||
},
|
||||
"bookingUrl": {
|
||||
"type": "string",
|
||||
"description": "Full booking URL for this private link",
|
||||
"format": "uri",
|
||||
"example": "https://cal.com/d/abc123def456/30min"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of times this link can be used",
|
||||
"example": 10
|
||||
},
|
||||
"usageCount": {
|
||||
"type": "number",
|
||||
"description": "Current usage count for this link",
|
||||
"example": 3
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"linkId",
|
||||
"eventTypeId",
|
||||
"isExpired",
|
||||
"bookingUrl",
|
||||
"maxUsageCount",
|
||||
"usageCount"
|
||||
]
|
||||
},
|
||||
"CreatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Created private link data (either time-based or usage-based)",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetPrivateLinksOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "Array of private links for the event type (mix of time-based and usage-based)",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdatePrivateLinkBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"description": "New expiration date for time-based links",
|
||||
"example": "2024-12-31T23:59:59.000Z"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "New maximum number of times the link can be used",
|
||||
"example": 10,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Updated private link data (either time-based or usage-based)",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"DeletePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Deleted link information",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Private link deleted successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UserWebhookOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -36,4 +36,5 @@ process.env = {
|
||||
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
|
||||
INTEGRATION_TEST_MODE: "true",
|
||||
e2e: "true",
|
||||
SLOTS_CACHE_TTL: "1"
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
|
||||
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
|
||||
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
|
||||
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
|
||||
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -16,7 +16,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyCtx, buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
import CachedClientView, { type TeamBookingPageProps } from "~/team/type-view-cached";
|
||||
|
||||
@@ -124,19 +124,17 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
// Handle org redirects for non-org contexts
|
||||
if (!isOrgContext) {
|
||||
const redirectResult = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: legacyCtx.query,
|
||||
});
|
||||
if (redirectResult) return redirect(redirectResult.redirect.destination);
|
||||
}
|
||||
// Handle org redirects
|
||||
const redirectResult = await handleOrgRedirect({
|
||||
slugs: [teamSlug],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
context: legacyCtx,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
if (redirectResult) return redirect(redirectResult.redirect.destination);
|
||||
|
||||
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { PageProps as ClientPageProps } from "~/getting-started/[[...step]]/onboarding-view";
|
||||
import Page from "~/getting-started/[[...step]]/onboarding-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: ServerPageProps) => {
|
||||
@@ -23,13 +26,28 @@ export const generateMetadata = async ({ params }: ServerPageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const props = await getData(context);
|
||||
return <Page {...props} />;
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const meCaller = await createRouterCaller(meRouter);
|
||||
|
||||
const [userTeams, user] = await Promise.all([
|
||||
userRepo.findUserTeams({
|
||||
id: session.user.id,
|
||||
}),
|
||||
meCaller.get(),
|
||||
]);
|
||||
|
||||
if (!userTeams || !user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return <Page user={user} hasPendingInvites={!!userTeams.teams.find((team) => team.accepted === false)} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetReq
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const body = await parseRequestData(req);
|
||||
@@ -28,7 +29,7 @@ async function handler(req: NextRequest) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: ip,
|
||||
identifier: piiHasher.hash(ip),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,13 +4,16 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
// This is the callback endpoint for the OIDC provider
|
||||
// A team must set this endpoint in the OIDC provider's configuration
|
||||
async function handler(req: NextRequest) {
|
||||
const log = logger.getSubLogger({ prefix: ["[ODIC auth]"] });
|
||||
const { searchParams } = req.nextUrl;
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const tenant = searchParams.get("tenant");
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ message: "Code and state are required" }, { status: 400 });
|
||||
@@ -30,6 +33,7 @@ async function handler(req: NextRequest) {
|
||||
|
||||
return NextResponse.redirect(redirect_url, 302);
|
||||
} catch (err) {
|
||||
log.error(`Error authorizing tenant ${tenant}: ${err}`);
|
||||
const { message, statusCode = 500 } = err as HttpError;
|
||||
|
||||
return NextResponse.json({ message }, { status: statusCode });
|
||||
|
||||
@@ -5,17 +5,20 @@ import { NextResponse } from "next/server";
|
||||
import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const log = logger.getSubLogger({ prefix: ["[SAML authorize]"] });
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
const oAuthReq = Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq;
|
||||
|
||||
try {
|
||||
const { redirect_url } = await oauthController.authorize(
|
||||
Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq
|
||||
);
|
||||
const { redirect_url } = await oauthController.authorize(oAuthReq);
|
||||
|
||||
return NextResponse.redirect(redirect_url as string, 302);
|
||||
} catch (err) {
|
||||
log.error(`Error initaiting SAML login for tenant ${oAuthReq?.tenant}: ${err}`);
|
||||
const { message, statusCode = 500 } = err as HttpError;
|
||||
|
||||
return NextResponse.json({ message }, { status: statusCode });
|
||||
|
||||
@@ -2,19 +2,34 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const uid = uuid();
|
||||
const log = logger.getSubLogger({ prefix: ["[SAML callback]", `trace: ${uid}`] });
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
const { redirect_url } = await oauthController.samlResponse(
|
||||
(await parseRequestData(req)) as SAMLResponsePayload
|
||||
);
|
||||
const requestData = (await parseRequestData(req)) as SAMLResponsePayload;
|
||||
|
||||
if (redirect_url) {
|
||||
return NextResponse.redirect(redirect_url, 302);
|
||||
try {
|
||||
const { redirect_url, error } = await oauthController.samlResponse(requestData);
|
||||
|
||||
if (redirect_url) {
|
||||
return NextResponse.redirect(redirect_url, 302);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const uid = uuid();
|
||||
log.error(`Error authenticating user with error ${error} for relayState ${requestData?.RelayState}`);
|
||||
return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error processing SAML response`, error);
|
||||
return NextResponse.json({ message: `Error processing SAML response. trace: ${uid}` }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 });
|
||||
|
||||
@@ -2,14 +2,26 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const { oauthController } = await jackson();
|
||||
const tokenResponse = await oauthController.token((await parseRequestData(req)) as OAuthTokenReq);
|
||||
return NextResponse.json(tokenResponse);
|
||||
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
|
||||
|
||||
const oauthTokenReq = (await parseRequestData(req)) as OAuthTokenReq;
|
||||
|
||||
try {
|
||||
const tokenResponse = await oauthController.token(oauthTokenReq);
|
||||
return NextResponse.json(tokenResponse);
|
||||
} catch (error) {
|
||||
const uid = uuid();
|
||||
log.error(`Error getting auth token for client id ${oauthTokenReq?.client_id}: ${error} trace: ${uid}`);
|
||||
throw new Error(`Error getting auth token with error ${error} trace: ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import z from "zod";
|
||||
import { uuid } from "short-uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
const extractAuthToken = (req: NextRequest) => {
|
||||
const log = logger.getSubLogger({ prefix: ["SAML extractAuthToken"] });
|
||||
const uid = uuid();
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const parts = (authHeader || "").split(" ");
|
||||
if (parts.length > 1) return parts[1];
|
||||
|
||||
// check for query param
|
||||
let arr: string[] = [];
|
||||
const { access_token } = requestQuery.parse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
const tokenParse = requestQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
let access_token;
|
||||
if (!tokenParse.success) {
|
||||
log.error(`Error parsing request query: ${tokenParse.error} trace ${uid}`);
|
||||
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
|
||||
}
|
||||
access_token = tokenParse.data.access_token;
|
||||
arr = arr.concat(access_token);
|
||||
if (arr[0].length > 0) return arr[0];
|
||||
|
||||
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
|
||||
};
|
||||
|
||||
const requestQuery = z.object({
|
||||
@@ -25,10 +35,18 @@ const requestQuery = z.object({
|
||||
});
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const log = logger.getSubLogger({ prefix: ["SAML userinfo"] });
|
||||
const { oauthController } = await jackson();
|
||||
const token = extractAuthToken(req);
|
||||
const userInfo = await oauthController.userInfo(token);
|
||||
return NextResponse.json(userInfo);
|
||||
|
||||
try {
|
||||
const userInfo = await oauthController.userInfo(token);
|
||||
return NextResponse.json(userInfo);
|
||||
} catch (error) {
|
||||
const uid = uuid();
|
||||
log.error(`trace: ${uid} Error getting user info from token: ${error}`);
|
||||
throw new Error(`Error getting user info from token. trace: ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
|
||||
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
|
||||
|
||||
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
|
||||
@@ -35,20 +35,31 @@ async function handler(req: NextRequest) {
|
||||
const imageType = searchParams.get("type");
|
||||
|
||||
try {
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
const fontResults = await Promise.allSettled([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
|
||||
const fonts: SatoriOptions["fonts"] = [];
|
||||
|
||||
if (fontResults[1].status === "fulfilled") {
|
||||
fonts.push({ name: "inter", data: fontResults[1].value, weight: 400 });
|
||||
}
|
||||
|
||||
if (fontResults[2].status === "fulfilled") {
|
||||
fonts.push({ name: "inter", data: fontResults[2].value, weight: 500 });
|
||||
}
|
||||
|
||||
if (fontResults[0].status === "fulfilled") {
|
||||
fonts.push({ name: "cal", data: fontResults[0].value, weight: 400 });
|
||||
fonts.push({ name: "cal", data: fontResults[0].value, weight: 600 });
|
||||
}
|
||||
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
fonts,
|
||||
};
|
||||
|
||||
switch (imageType) {
|
||||
|
||||
@@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="title-and-attendees"
|
||||
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
@@ -893,7 +891,7 @@ const FirstAttendee = ({
|
||||
) : (
|
||||
<a
|
||||
key={user.email}
|
||||
className=" hover:text-blue-500"
|
||||
className="hover:text-blue-500"
|
||||
href={`mailto:${user.email}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{user.name || user.email}
|
||||
@@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end p-2 ">
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
data-testid="update-no-show"
|
||||
color="secondary"
|
||||
@@ -1204,7 +1202,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end space-x-2 p-2 ">
|
||||
<div className="flex justify-end space-x-2 p-2">
|
||||
<Link href={`mailto:${selectedEmail}`}>
|
||||
<Button
|
||||
color="secondary"
|
||||
|
||||
@@ -278,13 +278,14 @@ describe("Booking Actions", () => {
|
||||
expect(rerouteAction).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include reassign action for round robin events", () => {
|
||||
it("should include reassign action for round robin events with no host groups", () => {
|
||||
const context = createMockContext({
|
||||
booking: {
|
||||
...createMockContext().booking,
|
||||
eventType: {
|
||||
...createMockContext().booking.eventType,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
hostGroups: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -294,6 +295,60 @@ describe("Booking Actions", () => {
|
||||
expect(reassignAction).toBeDefined();
|
||||
});
|
||||
|
||||
it("should include reassign action for round robin events with one host group", () => {
|
||||
const context = createMockContext({
|
||||
booking: {
|
||||
...createMockContext().booking,
|
||||
eventType: {
|
||||
...createMockContext().booking.eventType,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
hostGroups: [{ id: "group-1", name: "Group 1" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const actions = getEditEventActions(context);
|
||||
|
||||
const reassignAction = actions.find((a) => a.id === "reassign");
|
||||
expect(reassignAction).toBeDefined();
|
||||
});
|
||||
|
||||
it("should exclude reassign action for round robin events with more than one host groups", () => {
|
||||
const context = createMockContext({
|
||||
booking: {
|
||||
...createMockContext().booking,
|
||||
eventType: {
|
||||
...createMockContext().booking.eventType,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
hostGroups: [
|
||||
{ id: "group-1", name: "Group 1" },
|
||||
{ id: "group-2", name: "Group 2" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const actions = getEditEventActions(context);
|
||||
|
||||
const reassignAction = actions.find((a) => a.id === "reassign");
|
||||
expect(reassignAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should exclude reassign action for non-round-robin events", () => {
|
||||
const context = createMockContext({
|
||||
booking: {
|
||||
...createMockContext().booking,
|
||||
eventType: {
|
||||
...createMockContext().booking.eventType,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
hostGroups: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const actions = getEditEventActions(context);
|
||||
|
||||
const reassignAction = actions.find((a) => a.id === "reassign");
|
||||
expect(reassignAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should exclude add_members when guests are disabled", () => {
|
||||
const context = createMockContext({
|
||||
booking: {
|
||||
|
||||
@@ -146,8 +146,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
icon: "user-plus",
|
||||
disabled: false,
|
||||
},
|
||||
// Reassign (if round robin)
|
||||
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
||||
// Reassign if round robin with no or one host groups
|
||||
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
|
||||
? {
|
||||
id: "reassign",
|
||||
label: t("reassign"),
|
||||
|
||||
+2
-2
@@ -144,7 +144,7 @@ const PlainContactForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<div className="fixed bottom-[1rem] right-[1rem] z-50">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
||||
<Button
|
||||
@@ -156,7 +156,7 @@ const PlainContactForm = () => {
|
||||
|
||||
<PopoverContent
|
||||
style={{ maxWidth: "450px", maxHeight: "650px" }}
|
||||
className="!bg-muted no-scrollbar mr-8 mb-2 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
|
||||
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<p className="mb-5 text-lg font-semibold">Contact support</p>
|
||||
<Button
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import PlainContactForm from "../../../lib/plain/PlainContactForm";
|
||||
import PlainContactForm from "../PlainContactForm";
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: vi.fn(),
|
||||
|
||||
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
||||
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
|
||||
@@ -15,7 +15,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { getRedirectWithOriginAndSearchString } from "@lib/handleOrgRedirect";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
@@ -64,17 +64,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: [username],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
// Get just the origin and searchString from the redirect to ensure that we don't redirect to a URL that exposes the real path to book the user for any other events \
|
||||
// This is important for a private booking link
|
||||
// e.g. http://app.cal.com/d/sgdthj8mu4nsLNTYi3fW2p/demo -> should redirect to -> http://acme.cal.com/d/sgdthj8mu4nsLNTYi3fW2p/demo and not to http://acme.cal.com/john/demo(which exposes the real path to book the user for any other events)
|
||||
const redirectWithOriginAndSearchString = await getRedirectWithOriginAndSearchString({
|
||||
slugs: [username],
|
||||
redirectType: RedirectType.User,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirectWithOriginAndSearchString) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
// App Router doesn't have access to the current path directly, so we build it manually
|
||||
destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${
|
||||
redirectWithOriginAndSearchString.searchString
|
||||
}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
name = profileUsername || username;
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
|
||||
|
||||
const mockData = {
|
||||
redirects: [] as {
|
||||
toUrl: string;
|
||||
from: string;
|
||||
redirectType: RedirectType;
|
||||
}[],
|
||||
};
|
||||
|
||||
function mockARedirectInDB({
|
||||
toUrl,
|
||||
slug,
|
||||
redirectType,
|
||||
}: {
|
||||
toUrl: string;
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
}) {
|
||||
mockData.redirects.push({
|
||||
toUrl,
|
||||
from: slug,
|
||||
redirectType,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
prismaMock.tempOrgRedirect.findMany.mockImplementation(({ where }) => {
|
||||
return new Promise((resolve) => {
|
||||
const tempOrgRedirects: typeof mockData.redirects = [];
|
||||
where.from.in.forEach((whereSlug: string) => {
|
||||
const matchingRedirect = mockData.redirects.find((redirect) => {
|
||||
return where.type === redirect.redirectType && whereSlug === redirect.from && where.fromOrgId === 0;
|
||||
});
|
||||
if (matchingRedirect) {
|
||||
tempOrgRedirects.push(matchingRedirect);
|
||||
}
|
||||
});
|
||||
resolve(tempOrgRedirects);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockData.redirects = [];
|
||||
});
|
||||
|
||||
describe("getTemporaryOrgRedirect", () => {
|
||||
it("should generate event-type URL without existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate event-type URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate User URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Profile URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL without query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking Profile Url", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking Profile Url - same order", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
|
||||
const redirect1 = await getTemporaryOrgRedirect({
|
||||
slugs: ["second", "first"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect1).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/second-in-org1+first-in-org1?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking Profile Url with query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking EventType Url", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking EventType Url with query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||
export const getTemporaryOrgRedirect = async ({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
}: {
|
||||
slugs: string[] | string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
slugs = slugs instanceof Array ? slugs : [slugs];
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
safeStringify({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
})
|
||||
);
|
||||
|
||||
const redirects = await prisma.tempOrgRedirect.findMany({
|
||||
where: {
|
||||
type: redirectType,
|
||||
from: {
|
||||
in: slugs,
|
||||
},
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const currentQueryString = stringify(currentQuery);
|
||||
if (!redirects.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
|
||||
const newOrigin = new URL(redirects[0].toUrl).origin;
|
||||
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
|
||||
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
|
||||
const newSlugs = slugs.map((slug) => {
|
||||
const redirect = redirects.find((redirect) => redirect.from === slug);
|
||||
if (!redirect) {
|
||||
return slug;
|
||||
}
|
||||
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
|
||||
return newSlug;
|
||||
});
|
||||
|
||||
const newSlug = newSlugs.join("+");
|
||||
const newPath = newSlug ? `/${newSlug}` : "";
|
||||
|
||||
const newDestination = `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
|
||||
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: newDestination,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req } = context;
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.findUserTeams({
|
||||
id: session.user.id,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User from session not found");
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,704 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { handleOrgRedirect, getRedirectWithOriginAndSearchString } from "./handleOrgRedirect";
|
||||
|
||||
// Mock prisma for all tests
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
tempOrgRedirect: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
const createTestContext = (overrides?: {
|
||||
host?: string;
|
||||
query?: ParsedUrlQuery;
|
||||
}): GetServerSidePropsContext =>
|
||||
({
|
||||
req: {
|
||||
headers: {
|
||||
host: overrides?.host || "cal.local:3000",
|
||||
},
|
||||
},
|
||||
query: overrides?.query || {},
|
||||
} as unknown as GetServerSidePropsContext);
|
||||
|
||||
const createTestRedirectParams = (overrides?: {
|
||||
slugs?: string[];
|
||||
redirectType?: RedirectType;
|
||||
eventTypeSlug?: string | null;
|
||||
context?: GetServerSidePropsContext;
|
||||
currentOrgDomain?: string | null;
|
||||
}) => ({
|
||||
slugs: overrides?.slugs || ["pro"],
|
||||
redirectType: overrides?.redirectType || RedirectType.User,
|
||||
eventTypeSlug: overrides?.eventTypeSlug ?? null,
|
||||
context: overrides?.context || createTestContext(),
|
||||
currentOrgDomain: overrides?.currentOrgDomain ?? null,
|
||||
});
|
||||
|
||||
interface RedirectScenario {
|
||||
redirects: Array<{
|
||||
from: string;
|
||||
toUrl: string;
|
||||
type?: RedirectType;
|
||||
}>;
|
||||
}
|
||||
|
||||
const createRedirectScenario = (scenario: RedirectScenario) => {
|
||||
// Create a map for quick lookup based on input parameters
|
||||
const redirectMap = new Map<string, (typeof scenario.redirects)[0]>();
|
||||
|
||||
scenario.redirects.forEach((redirect) => {
|
||||
const key = `${redirect.type || RedirectType.User}-${redirect.from}`;
|
||||
redirectMap.set(key, redirect);
|
||||
});
|
||||
|
||||
// Return a mock implementation that returns data based on the actual query
|
||||
prismaMock.tempOrgRedirect.findMany.mockImplementation(async (params) => {
|
||||
const { type, from } = params?.where || {};
|
||||
const slugsToFind = from?.in || [];
|
||||
|
||||
// Return redirects that match the requested type and slugs
|
||||
const matchingRedirects = slugsToFind
|
||||
.map((slug: string) => {
|
||||
const key = `${type}-${slug}`;
|
||||
const redirect = redirectMap.get(key);
|
||||
if (redirect) {
|
||||
return {
|
||||
from: redirect.from,
|
||||
toUrl: redirect.toUrl,
|
||||
type: redirect.type || type,
|
||||
fromOrgId: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return matchingRedirects;
|
||||
});
|
||||
};
|
||||
|
||||
const expectRedirectTo = (result: any, expectedDestination: string) => {
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: expectedDestination,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expectNoRedirect = (result: any) => {
|
||||
expect(result).toBeNull();
|
||||
};
|
||||
|
||||
const expectPrismaCalledWith = (expectedParams: { type: RedirectType; slugs: string[] }) => {
|
||||
expect(prismaMock.tempOrgRedirect.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type: expectedParams.type,
|
||||
from: {
|
||||
in: expectedParams.slugs,
|
||||
},
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expectPrismaNotCalled = () => {
|
||||
expect(prismaMock.tempOrgRedirect.findMany).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
// Verify that the redirect was actually built from the data returned by Prisma
|
||||
const expectRedirectUsesData = (
|
||||
result: any,
|
||||
expectedSlug: string,
|
||||
expectedOrigin = "https://acme.cal.local:3000"
|
||||
) => {
|
||||
if (!result || !result.redirect) {
|
||||
throw new Error("Expected a redirect result but got null/undefined");
|
||||
}
|
||||
const url = new URL(result.redirect.destination);
|
||||
expect(url.origin).toBe(expectedOrigin);
|
||||
expect(url.pathname).toContain(expectedSlug);
|
||||
};
|
||||
|
||||
describe("handleOrgRedirect", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to no redirects - tests will override with specific scenarios
|
||||
createRedirectScenario({ redirects: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("when not in organization context", () => {
|
||||
it("should redirect to organization URL when redirect exists", async () => {
|
||||
// Setup scenario with unique test data to verify correct usage
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
// Add a decoy redirect to ensure the code picks the right one
|
||||
{
|
||||
from: "other-user",
|
||||
toUrl: "https://acme.cal.local:3000/wrong-redirect",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["pro"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "pro-example");
|
||||
});
|
||||
|
||||
it("should return null when no redirect exists", async () => {
|
||||
// Explicitly set up no redirects scenario
|
||||
createRedirectScenario({ redirects: [] });
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["pro"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
|
||||
it("should handle Team redirect type", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "sales-team",
|
||||
toUrl: "https://acme.cal.local:3000/sales",
|
||||
type: RedirectType.Team,
|
||||
},
|
||||
// Add a User redirect with same slug to verify type filtering
|
||||
{
|
||||
from: "sales-team",
|
||||
toUrl: "https://acme.cal.local:3000/wrong-user-redirect",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["sales-team"],
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.Team, slugs: ["sales-team"] });
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/sales?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "sales");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when SINGLE_ORG_SLUG mode is enabled", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue("acme");
|
||||
});
|
||||
|
||||
it("should redirect using relative path when accessing from base domain", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({ host: "cal.local:3000" }),
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
// Should use relative path to stay on same domain
|
||||
expectRedirectTo(result, "/pro-example?orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should redirect john87 to john using relative path in single org mode", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john87",
|
||||
toUrl: "https://acme.cal.local:3000/john",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["john87"],
|
||||
context: createTestContext({
|
||||
host: "my-instance.com",
|
||||
}),
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["john87"] });
|
||||
// Should redirect to relative path /john to stay on my-instance.com
|
||||
expectRedirectTo(result, "/john?orgRedirection=true");
|
||||
});
|
||||
|
||||
describe("when orgRedirection parameter is present", () => {
|
||||
it("should not redirect to prevent loops", async () => {
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({ query: { orgRedirection: "true" } }),
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaNotCalled();
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when event type slug is provided", () => {
|
||||
it("should include event type slug in redirect destination", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
eventTypeSlug: "30min",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example/30min?orgRedirection=true");
|
||||
// Verify the event type slug was properly appended
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/pro-example/30min");
|
||||
});
|
||||
|
||||
it("should handle empty event type slug", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
eventTypeSlug: null,
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "pro-example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when handling multiple slugs (group bookings)", () => {
|
||||
it("should preserve order of slugs in redirect", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user1",
|
||||
toUrl: "https://acme.cal.local:3000/org-user1",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
{
|
||||
from: "user2",
|
||||
toUrl: "https://acme.cal.local:3000/org-user2",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
{
|
||||
from: "user3",
|
||||
toUrl: "https://acme.cal.local:3000/org-user3",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user1", "user2", "user3"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(
|
||||
result,
|
||||
"https://acme.cal.local:3000/org-user1+org-user2+org-user3?orgRedirection=true"
|
||||
);
|
||||
// Verify order is preserved from input slugs
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/org-user1+org-user2+org-user3");
|
||||
});
|
||||
|
||||
it("should handle partial redirects in group bookings", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user1",
|
||||
toUrl: "https://acme.cal.local:3000/org-user1",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
// user2 has no redirect - it should stay as-is
|
||||
{
|
||||
from: "user3",
|
||||
toUrl: "https://acme.cal.local:3000/org-user3",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user1", "user2", "user3"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/org-user1+user2+org-user3?orgRedirection=true");
|
||||
// Verify that user2 remains unchanged while others are redirected
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/org-user1+user2+org-user3");
|
||||
});
|
||||
|
||||
it("should handle different origins in redirects", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user1",
|
||||
toUrl: "https://org1.cal.com/john",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
{
|
||||
from: "user2",
|
||||
toUrl: "https://org1.cal.com/jane",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user1", "user2"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
// Should use the first redirect's origin
|
||||
expectRedirectTo(result, "https://org1.cal.com/john+jane?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "john+jane", "https://org1.cal.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when handling query parameters", () => {
|
||||
it("should add orgRedirection=true to empty query", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({ query: {} }),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "pro-example");
|
||||
});
|
||||
|
||||
it("should preserve existing query parameters", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({
|
||||
query: { abc: "1", xyz: "test" },
|
||||
}),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?abc=1&xyz=test&orgRedirection=true");
|
||||
// Verify query parameters are preserved
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.searchParams.get("abc")).toBe("1");
|
||||
expect(url.searchParams.get("xyz")).toBe("test");
|
||||
});
|
||||
|
||||
it("should not duplicate orgRedirection parameter", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({
|
||||
query: { orgRedirection: "true", abc: "1" },
|
||||
}),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?abc=1&orgRedirection=true");
|
||||
|
||||
// Verify only one orgRedirection parameter exists
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
const orgRedirectionParams = url.searchParams.getAll("orgRedirection");
|
||||
expect(orgRedirectionParams).toHaveLength(1);
|
||||
expect(orgRedirectionParams[0]).toBe("true");
|
||||
});
|
||||
|
||||
it("should handle array query parameters", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({
|
||||
query: { tags: ["tag1", "tag2"], filter: "active" },
|
||||
}),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.searchParams.getAll("tags")).toEqual(["tag1", "tag2"]);
|
||||
expect(url.searchParams.get("filter")).toBe("active");
|
||||
expect(url.searchParams.get("orgRedirection")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases and error scenarios", () => {
|
||||
it("should handle empty slugs array", async () => {
|
||||
createRedirectScenario({ redirects: [] });
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: [],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: [] });
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
|
||||
it("should handle redirects with trailing slashes", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example/",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams();
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example/?orgRedirection=true");
|
||||
// Verify trailing slash is preserved
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/pro-example/");
|
||||
});
|
||||
|
||||
it("should handle redirects to root path", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams();
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000?orgRedirection=true");
|
||||
// Verify root path redirect
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/");
|
||||
});
|
||||
|
||||
it("should handle special characters in slugs", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user-name.test",
|
||||
toUrl: "https://acme.cal.local:3000/user_name_test",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user-name.test"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/user_name_test?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "user_name_test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when SINGLE_ORG_SLUG is not set", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("should not check for redirects when in org context", async () => {
|
||||
const params = createTestRedirectParams({
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaNotCalled();
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRedirectWithOriginAndSearchString", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
prismaMock.tempOrgRedirect.findMany.mockReset();
|
||||
});
|
||||
|
||||
it("should return origin and search string for absolute URL redirects", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john",
|
||||
toUrl: "https://acme.cal.local:3000/john-org",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext(),
|
||||
currentOrgDomain: null,
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.origin).toBe("https://acme.cal.local:3000");
|
||||
expect(result?.searchString).toBe("?orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should return null origin for relative path redirects", async () => {
|
||||
// Enable SINGLE_ORG_SLUG for this test to get relative paths
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue("acme");
|
||||
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john",
|
||||
toUrl: "https://acme.cal.local:3000/john-org",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext({ host: "my-instance.com" }),
|
||||
currentOrgDomain: "acme",
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.origin).toBeNull(); // Relative path has no origin
|
||||
expect(result?.searchString).toBe("?orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should preserve existing query parameters in search string", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john",
|
||||
toUrl: "https://acme.cal.local:3000/john-org",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext({ query: { foo: "bar", baz: "qux" } }),
|
||||
currentOrgDomain: null,
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.origin).toBe("https://acme.cal.local:3000");
|
||||
expect(result?.searchString).toBe("?foo=bar&baz=qux&orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should return null when no redirect exists", async () => {
|
||||
createRedirectScenario({ redirects: [] });
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext(),
|
||||
currentOrgDomain: null,
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SINGLE_ORG_SLUG } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "handleOrgRedirect"] });
|
||||
type NextJsRedirect = {
|
||||
redirect: {
|
||||
permanent: false;
|
||||
/**
|
||||
* It could be a full URL or a relative path
|
||||
*/
|
||||
destination: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getSearchString(relativeOrAbsoluteUrl: string) {
|
||||
const url = new URL(relativeOrAbsoluteUrl, "http://localhost");
|
||||
return url.search;
|
||||
}
|
||||
|
||||
const getTemporaryOrgRedirect = async ({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
useRelativePath = false,
|
||||
}: {
|
||||
slugs: string[];
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
useRelativePath?: boolean;
|
||||
}): Promise<NextJsRedirect | null> => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
safeStringify({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
})
|
||||
);
|
||||
|
||||
const redirects = await prisma.tempOrgRedirect.findMany({
|
||||
where: {
|
||||
type: redirectType,
|
||||
from: {
|
||||
in: slugs,
|
||||
},
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (!redirects.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
|
||||
const newOrigin = new URL(redirects[0].toUrl).origin;
|
||||
|
||||
// Filter out any existing orgRedirection parameter to avoid duplicates
|
||||
// querystring.stringify transforms undefined values to empty strings, so we need to filter those out as well initially
|
||||
const filteredQuery = Object.entries(currentQuery).reduce((acc, [key, value]) => {
|
||||
if (key !== "orgRedirection" && value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as ParsedUrlQuery);
|
||||
|
||||
const currentQueryString = stringify(filteredQuery);
|
||||
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
|
||||
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
|
||||
const newSlugs = slugs.map((slug) => {
|
||||
const redirect = redirects.find((redirect) => redirect.from === slug);
|
||||
if (!redirect) {
|
||||
return slug;
|
||||
}
|
||||
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
|
||||
return newSlug;
|
||||
});
|
||||
|
||||
const newSlug = newSlugs.join("+");
|
||||
const newPath = newSlug ? `/${newSlug}` : "";
|
||||
|
||||
// When in single org mode and not on actual org subdomain, use relative path to stay on same domain
|
||||
const newDestination = useRelativePath
|
||||
? `${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`
|
||||
: `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
|
||||
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: newDestination,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
interface HandleOrgRedirectParams {
|
||||
slugs: string[];
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
context: {
|
||||
query: ParsedUrlQuery;
|
||||
};
|
||||
currentOrgDomain: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles organization redirects for both regular org context and SINGLE_ORG_SLUG mode
|
||||
* The redirect is required for all existing user links and team links to keep working when a user/team is moved to an organization
|
||||
* Example:
|
||||
* - User "john87" is added to organization "acme" and his username in the organization is "john". So, cal.com/john87 is redirected to cal.com/john
|
||||
* - Team "acme-sales" is added to organization "acme" and its slug in the organization is "sales". So, cal.com/acme-sales is redirected to cal.com/sales
|
||||
*
|
||||
* Returns a redirect object if a redirect is needed, null otherwise
|
||||
*/
|
||||
export async function handleOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
context,
|
||||
currentOrgDomain,
|
||||
}: HandleOrgRedirectParams) {
|
||||
const isOrgContext = !!currentOrgDomain;
|
||||
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
|
||||
|
||||
// If we're not in an org context, the request could clearly be eligible for a redirect
|
||||
if (!isOrgContext) {
|
||||
const nextJsRedirect = await getTemporaryOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (nextJsRedirect) {
|
||||
return nextJsRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
const isSingleOrgMode = SINGLE_ORG_SLUG && isOrgContext;
|
||||
|
||||
// When SINGLE_ORG_SLUG is set(which is possible in Self-Hosted instances), isOrgContext could be true even when the current domain is not an org subdomain
|
||||
// Example: my-instance.com could technically mean acme.my-instance.com where acme is the org slug
|
||||
// In such a case, the existing links which were on my-instance.com e.g. my-instance.com/john87 should be redirected to my-instance.com/john where john is the new username in Organization
|
||||
// This is why we need to follow redirect even when in Org Context
|
||||
if (!isSingleOrgMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If already redirected from a non-org link, we shouldn't redirect again. Protects against infinite redirects
|
||||
if (isARedirectFromNonOrgLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We're in single org mode but not on an actual org subdomain
|
||||
// Check if there's a redirect for this username
|
||||
// Use relative path since we want to stay on the same domain (my-instance.com)
|
||||
const nextJsRedirect = await getTemporaryOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery: context.query,
|
||||
useRelativePath: true,
|
||||
});
|
||||
|
||||
return nextJsRedirect;
|
||||
}
|
||||
|
||||
export async function getRedirectWithOriginAndSearchString({
|
||||
slugs,
|
||||
redirectType,
|
||||
context,
|
||||
currentOrgDomain,
|
||||
}: Omit<HandleOrgRedirectParams, "eventTypeSlug">) {
|
||||
const nextJsRedirect = await handleOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug: null,
|
||||
context,
|
||||
currentOrgDomain,
|
||||
});
|
||||
if (!nextJsRedirect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newDestination = nextJsRedirect.redirect.destination;
|
||||
// If there is an origin use it, otherwise mark no new Origin and it remains the same domain
|
||||
const newOrigin =
|
||||
newDestination.startsWith("http://") || newDestination.startsWith("https://")
|
||||
? new URL(newDestination).origin
|
||||
: null;
|
||||
|
||||
return {
|
||||
origin: newOrigin,
|
||||
searchString: getSearchString(newDestination),
|
||||
};
|
||||
}
|
||||
@@ -23,15 +23,18 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (team) {
|
||||
return GSSTeamPage({
|
||||
...ctx,
|
||||
query: { slug: ctx.query.user, orgRedirection: ctx.query.orgRedirection },
|
||||
query: {
|
||||
slug: ctx.query.user,
|
||||
...(ctx.query.orgRedirection !== undefined && { orgRedirection: ctx.query.orgRedirection }),
|
||||
},
|
||||
});
|
||||
}
|
||||
return GSSUserPage({
|
||||
...ctx,
|
||||
query: {
|
||||
user: ctx.query.user,
|
||||
redirect: ctx.query.redirect,
|
||||
orgRedirection: ctx.query.orgRedirection,
|
||||
...(ctx.query.redirect !== undefined && { redirect: ctx.query.redirect }),
|
||||
...(ctx.query.orgRedirection !== undefined && { orgRedirection: ctx.query.orgRedirection }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ import dynamic from "next/dynamic";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
|
||||
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
|
||||
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID
|
||||
? dynamic(() => import("../../components/plain/plainChat"))
|
||||
: Fragment;
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { User } from "@calcom/prisma/client";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
@@ -33,19 +33,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: [teamSlug],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Team, OrganizationSettings } from "@calcom/prisma/client";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
|
||||
|
||||
@@ -67,7 +67,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
context.req,
|
||||
context.params?.orgSlug ?? context.query?.orgSlug
|
||||
);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
@@ -89,12 +88,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
isOrgView: isValidOrgDomain && isOrgProfile,
|
||||
});
|
||||
|
||||
if (!isOrgContext && slug) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: slug,
|
||||
if (slug) {
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: [slug],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
||||
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
|
||||
});
|
||||
|
||||
// If eventType exists, it means user is either owner, host or user
|
||||
return !eventType;
|
||||
return !!eventType;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
@@ -10,16 +10,13 @@ import { z } from "zod";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { StepCard } from "@calcom/ui/components/card";
|
||||
import { Steps } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
|
||||
|
||||
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
||||
import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep";
|
||||
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
|
||||
@@ -85,13 +82,16 @@ const stepRouteSchema = z.object({
|
||||
from: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
type PageProps = {
|
||||
hasPendingInvites: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
};
|
||||
|
||||
const OnboardingPage = (props: PageProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const user = props.user;
|
||||
const router = useRouter();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const [isNextStepLoading, startTransition] = useTransition();
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} from "@calcom/features/insights/components/booking";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { Download } from "@calcom/features/insights/filters/Download";
|
||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||
import { showToast } from "@calcom/ui/toast";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const BUTTONS = {
|
||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||
},
|
||||
START_TRANSCRIPTION: {
|
||||
label: "Cal.ai",
|
||||
label: "Transcribe",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function JoinCall(props: PageProps) {
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
label: "Transcribe",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
|
||||
@@ -179,6 +179,8 @@ const nextConfig = {
|
||||
"superagent-proxy", // Dependencies of @tryvital/vital-node
|
||||
"superagent", // Dependencies of akismet
|
||||
"formidable", // Dependencies of akismet
|
||||
"@boxyhq/saml-jackson",
|
||||
"jose", // Dependency of @boxyhq/saml-jackson
|
||||
],
|
||||
experimental: {
|
||||
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.10",
|
||||
"version": "5.5.16",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -76,10 +76,9 @@
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@tremor/react": "^2.11.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@unkey/ratelimit": "^0.1.1",
|
||||
"@upstash/redis": "^1.21.0",
|
||||
"@upstash/redis": "^1.35.2",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
"@vercel/og": "^0.6.3",
|
||||
@@ -139,6 +138,7 @@
|
||||
"react-use-intercom": "1.5.1",
|
||||
"recoil": "^0.7.7",
|
||||
"remove-markdown": "^0.5.0",
|
||||
"retell-sdk": "^4.40.0",
|
||||
"rrule": "^2.7.1",
|
||||
"sanitize-html": "^2.10.0",
|
||||
"schema-dts": "^1.1.0",
|
||||
|
||||
@@ -57,3 +57,22 @@ export const applyTheme = function () {
|
||||
console.error("Error applying theme:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// ToDesktop class application function - will be stringified and injected. So, it must not use anything from the closure
|
||||
export const applyToDesktopClass = function () {
|
||||
try {
|
||||
const onReady = () => {
|
||||
if (typeof window !== "undefined" && window.todesktop && document.documentElement) {
|
||||
document.documentElement.classList.add("todesktop");
|
||||
} else if (document.documentElement) {
|
||||
return;
|
||||
} else {
|
||||
requestAnimationFrame(onReady);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(onReady);
|
||||
} catch (e) {
|
||||
console.error("Error applying todesktop class:", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { applyTheme } from "./_applyThemeForDocument";
|
||||
import { applyTheme, applyToDesktopClass } from "./_applyThemeForDocument";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
|
||||
|
||||
@@ -50,6 +50,7 @@ class MyDocument extends Document<Props> {
|
||||
__html: `
|
||||
window.calNewLocale = "${newLocale}";
|
||||
(${applyTheme.toString()})();
|
||||
(${applyToDesktopClass.toString()})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import getIP from "@calcom/lib/getIP";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const userIp = getIP(req);
|
||||
@@ -20,7 +21,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
identifier: piiHasher.hash(userIp),
|
||||
});
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
@@ -6,13 +6,14 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `instant.event-${userIp}`,
|
||||
identifier: `instant.event-${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||
|
||||
@@ -37,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: userIp,
|
||||
identifier: piiHasher.hash(userIp),
|
||||
});
|
||||
const session = await getServerSession({ req });
|
||||
/* To mimic API behavior and comply with types */
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
|
||||
import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Choose User filter item from dropdown
|
||||
await addFilter(page, "bookingUserId");
|
||||
await addFilter(page, "userId");
|
||||
|
||||
// Wait for the URL to include bookingUserId
|
||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
||||
// Wait for the URL to include userId
|
||||
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await openFilter(page, "bookingUserId");
|
||||
await openFilter(page, "userId");
|
||||
|
||||
await page
|
||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(0)
|
||||
.click();
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||
|
||||
await page
|
||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await clearFilters(page);
|
||||
|
||||
await expect(page.url()).not.toContain("bookingUserId");
|
||||
await expect(page.url()).not.toContain("userId");
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
|
||||
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
|
||||
"fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.",
|
||||
"round_robin_hosts_unavailable_for_booking": "No Round Robin hosts is available for booking.",
|
||||
"round_robin_host_unavailable_for_booking": "Round Robin host is unavailable for booking.",
|
||||
"help": "Help",
|
||||
"price": "Price",
|
||||
"paid": "Paid",
|
||||
@@ -349,6 +349,7 @@
|
||||
"available_apps_lower_case": "Available apps",
|
||||
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
|
||||
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
|
||||
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
|
||||
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
"finish": "Finish",
|
||||
@@ -2251,6 +2252,8 @@
|
||||
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
|
||||
"pop_up_element_click": "Pop up via element click",
|
||||
"open_dialog_with_element_click": "Open your calendar as a dialog when someone clicks an element.",
|
||||
"use_my_own_form": "Use my own form",
|
||||
"use_our_headless_routing_api": "Use our headless routing API and use your own form for submissions.",
|
||||
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
|
||||
"book_my_cal": "Book my Cal",
|
||||
"first_name": "First name",
|
||||
@@ -3286,6 +3289,7 @@
|
||||
"pbac_resource_booking": "Bookings",
|
||||
"pbac_resource_insights": "Insights",
|
||||
"pbac_resource_role": "Roles",
|
||||
"pbac_resource_workflow": "Workflows",
|
||||
"pbac_action_all": "All Actions",
|
||||
"pbac_action_create": "Create",
|
||||
"pbac_action_read": "View",
|
||||
@@ -3317,6 +3321,11 @@
|
||||
"pbac_desc_update_roles": "Update roles",
|
||||
"pbac_desc_delete_roles": "Delete roles",
|
||||
"pbac_desc_manage_roles": "All actions on roles across organization teams",
|
||||
"pbac_desc_create_workflows": "Create and set up new workflows",
|
||||
"pbac_desc_view_workflows": "View existing workflows and their configurations",
|
||||
"pbac_desc_update_workflows": "Edit and modify workflow settings",
|
||||
"pbac_desc_delete_workflows": "Remove workflows from the system",
|
||||
"pbac_desc_manage_workflows": "Full management access to all workflows",
|
||||
"pbac_desc_create_event_types": "Create event types",
|
||||
"pbac_desc_view_event_types": "View event types",
|
||||
"pbac_desc_update_event_types": "Update event types",
|
||||
@@ -3354,6 +3363,7 @@
|
||||
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
||||
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
||||
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
||||
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
|
||||
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
||||
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
|
||||
"booking_creation_time": "Booking Creation Time",
|
||||
@@ -3370,6 +3380,7 @@
|
||||
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||
"add_group": "Add Group",
|
||||
"routing_funnel": "Routing Funnel",
|
||||
"routing_funnel_total_submissions": "Total Submissions",
|
||||
"routing_funnel_successful_routings": "Successful Routings",
|
||||
@@ -3402,6 +3413,7 @@
|
||||
"license_key_saved": "License key saved successfully",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"failed_bookings": "Failed bookings",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
"cache_status": "Cache Status",
|
||||
"cache_last_updated": "Last updated: {{timestamp}}",
|
||||
@@ -3429,5 +3441,36 @@
|
||||
"usage_based_expiration_description": "This link can be used for {{count}} booking",
|
||||
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
|
||||
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
|
||||
"webhook_trigger_event": "The name of the trigger event (e.g., BOOKING_CREATED, BOOKING_CANCELLED)",
|
||||
"webhook_created_at": "The time of the webhook",
|
||||
"webhook_type": "The event type slug",
|
||||
"webhook_title": "The event type name",
|
||||
"webhook_start_time": "The event's start time",
|
||||
"webhook_end_time": "The event's end time",
|
||||
"webhook_description": "The event's description as described in the event type settings",
|
||||
"webhook_location": "Location of the event",
|
||||
"webhook_uid": "The UID of the booking",
|
||||
"webhook_reschedule_uid": "The UID for rescheduling",
|
||||
"webhook_cancellation_reason": "Reason for cancellation",
|
||||
"webhook_rejection_reason": "Reason for rejection",
|
||||
"webhook_organizer_name": "Name of the organizer",
|
||||
"webhook_organizer_email": "Email of the organizer",
|
||||
"webhook_organizer_timezone": "Timezone of the organizer (e.g., 'America/New_York', 'Asia/Kolkata')",
|
||||
"webhook_organizer_locale": "Locale of the organizer (e.g., 'en', 'fr')",
|
||||
"webhook_attendee_name": "Name of the first attendee",
|
||||
"webhook_attendee_email": "Email of the first attendee",
|
||||
"webhook_attendee_timezone": "Timezone of the first attendee",
|
||||
"webhook_attendee_locale": "Locale of the first attendee",
|
||||
"webhook_team_name": "Name of the team booked",
|
||||
"webhook_team_members": "Members of the team booked",
|
||||
"webhook_video_call_url": "Video call URL for the meeting",
|
||||
"webhook_hide_variables": "Hide variables",
|
||||
"webhook_show_variable": "Show available variables",
|
||||
"webhook_event_and_booking": "Event and Booking",
|
||||
"webhook_people": "People",
|
||||
"webhook_teams": "Teams",
|
||||
"webhook_metadata": "Metadata",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
"connect": "Ühenda",
|
||||
"try_for_free": "Proovi tasuta",
|
||||
"create_booking_link_with_calcom": "Looge oma broneerimislink rakendusega {{appName}}",
|
||||
"who": "WHO",
|
||||
"who": "Kes",
|
||||
"what": "Mida",
|
||||
"when": "Millal",
|
||||
"where": "Kus",
|
||||
|
||||
@@ -15,7 +15,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
import { getUsersInOrgContext } from "@server/lib/[user]/getServerSideProps";
|
||||
|
||||
@@ -122,17 +122,17 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const allowRescheduleForCancelledBooking = context.query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
if (!org) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
context,
|
||||
currentOrgDomain: org,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
@@ -220,18 +220,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const allowRescheduleForCancelledBooking = context.query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const [user] = await getUsersInOrgContext([username], isValidOrgDomain ? currentOrgDomain : null);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[[pages/[user]]]"] });
|
||||
type UserPageProps = {
|
||||
@@ -73,25 +73,20 @@ type UserPageProps = {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isOrgContext = isValidOrgDomain && !!currentOrgDomain;
|
||||
|
||||
const dataFetchStart = Date.now();
|
||||
|
||||
if (!isOrgContext) {
|
||||
// If there is no org context, see if some redirect is setup due to org migration
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernameList,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: usernameList,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const usersInOrgContext = await getUsersInOrgContext(
|
||||
|
||||
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
...base,
|
||||
content: [
|
||||
...base.content,
|
||||
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
|
||||
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
|
||||
plugins: [...base.plugins, require("tailwindcss-animate")],
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/BookingLimits";
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
|
||||
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
|
||||
|
||||
import {
|
||||
getDate,
|
||||
@@ -16,19 +17,19 @@ import {
|
||||
import { describe, vi, test } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { SchedulingType, type BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
|
||||
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
|
||||
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
IS_PRODUCTION: true,
|
||||
constantsScenarios.set({
|
||||
IS_PRODUCTION: true as any,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
RESERVED_SUBDOMAINS: ["auth", "docs"],
|
||||
SINGLE_ORG_SLUG: "",
|
||||
}));
|
||||
RESERVED_SUBDOMAINS: ["auth", "docs"] as any,
|
||||
SINGLE_ORG_SLUG: "" as any,
|
||||
});
|
||||
|
||||
describe("getSchedule", () => {
|
||||
const availableSlotsService = getAvailableSlotsService();
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./expects";
|
||||
import { setupAndTeardown } from "./setupAndTeardown";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { expectNoAttemptToGetAvailability } from "../../utils/bookingScenario/ex
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./expects";
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ScenarioData } from "../../utils/bookingScenario/bookingScenario";
|
||||
|
||||
import { describe, expect, vi, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { PeriodType } from "@calcom/prisma/enums";
|
||||
|
||||
import { expectedSlotsForSchedule } from "./expects";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { describe, test, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
import { expect } from "./expects";
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IncomingMessage } from "http";
|
||||
import { describe, test, beforeEach, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { getScheduleSchema, GetScheduleOptions } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => key,
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ type InputHost = {
|
||||
userId: number;
|
||||
isFixed?: boolean;
|
||||
scheduleId?: number | null;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
type InputSelectedSlot = {
|
||||
@@ -215,6 +216,7 @@ export type InputEventType = {
|
||||
useEventLevelSelectedCalendars?: boolean;
|
||||
users?: { id: number }[];
|
||||
hosts?: InputHost[];
|
||||
hostGroups?: { id: string; name: string }[];
|
||||
schedulingType?: SchedulingType;
|
||||
parent?: { id: number };
|
||||
beforeEventBuffer?: number;
|
||||
@@ -290,6 +292,17 @@ export const Timezones = {
|
||||
|
||||
async function addHostsToDb(eventTypes: InputEventType[]) {
|
||||
for (const eventType of eventTypes) {
|
||||
// Create host groups first if they exist
|
||||
if (eventType.hostGroups?.length) {
|
||||
await prismock.hostGroup.createMany({
|
||||
data: eventType.hostGroups.map((group) => ({
|
||||
id: group.id, // Preserve the input ID
|
||||
name: group.name,
|
||||
eventTypeId: eventType.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (!eventType.hosts?.length) continue;
|
||||
for (const host of eventType.hosts) {
|
||||
const data: Prisma.HostCreateInput = {
|
||||
@@ -311,6 +324,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
group: host.groupId
|
||||
? {
|
||||
connect: {
|
||||
id: host.groupId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await prismock.host.create({
|
||||
@@ -363,6 +383,7 @@ export async function addEventTypesToDb(
|
||||
workflows: true,
|
||||
destinationCalendar: true,
|
||||
schedule: true,
|
||||
hostGroups: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -473,6 +494,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
|
||||
workflows: [],
|
||||
users,
|
||||
hosts,
|
||||
hostGroups: eventType.hostGroups || [],
|
||||
destinationCalendar: eventType.destinationCalendar
|
||||
? {
|
||||
create: eventType.destinationCalendar,
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"path": "/api/cron/calendar-cache-cleanup",
|
||||
"schedule": "0 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/queuedFormResponseCleanup",
|
||||
"schedule": "0 */12 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/tasks/cron",
|
||||
"schedule": "* * * * *"
|
||||
|
||||
@@ -9893,6 +9893,198 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/event-types/{eventTypeId}/private-links": {
|
||||
"post": {
|
||||
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
|
||||
"summary": "Create a private link for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePrivateLinkInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
|
||||
"summary": "Get all private links for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetPrivateLinksOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
|
||||
"patch": {
|
||||
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
|
||||
"summary": "Update a private link for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linkId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdatePrivateLinkBody"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
|
||||
"summary": "Delete a private link for an event type",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linkId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Event Types Private Links"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/organizations": {
|
||||
"post": {
|
||||
"operationId": "OrganizationsOrganizationsController_createOrganization",
|
||||
@@ -21856,7 +22048,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
]
|
||||
},
|
||||
"recipient": {
|
||||
@@ -22218,7 +22411,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_address",
|
||||
"description": "Action to perform, send an email to a specific email address",
|
||||
@@ -22303,7 +22497,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_attendee",
|
||||
"description": "Action to perform, send an email to the attendees of the event",
|
||||
@@ -22379,7 +22574,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_host",
|
||||
"description": "Action to perform, send an email to the host of the event",
|
||||
@@ -22474,7 +22670,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -22543,7 +22740,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_number",
|
||||
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
|
||||
@@ -22621,7 +22819,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_number",
|
||||
"description": "Action to perform, send a text message to a specific phone number",
|
||||
@@ -22699,7 +22898,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_attendee",
|
||||
"description": "Action to perform, send a text message to the phone numbers of the attendees",
|
||||
@@ -22888,7 +23088,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_address",
|
||||
"description": "Action to perform, send an email to a specific email address",
|
||||
@@ -22978,7 +23179,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_attendee",
|
||||
"description": "Action to perform, send an email to the attendees of the event",
|
||||
@@ -23059,7 +23261,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "email_host",
|
||||
"description": "Action to perform, send an email to the host of the event",
|
||||
@@ -23140,7 +23343,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_attendee",
|
||||
"description": "Action to perform, send a text message to the phone numbers of the attendees",
|
||||
@@ -23218,7 +23422,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_number",
|
||||
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
|
||||
@@ -23301,7 +23506,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -23375,7 +23581,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "sms_number",
|
||||
"description": "Action to perform, send a text message to a specific phone number",
|
||||
@@ -28012,6 +28219,230 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreatePrivateLinkInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"description": "Expiration date for time-based links",
|
||||
"format": "date-time",
|
||||
"example": "2024-12-31T23:59:59.000Z"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
|
||||
"example": 10,
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"TimeBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"description": "The private link ID",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event type ID this link belongs to",
|
||||
"example": 123
|
||||
},
|
||||
"isExpired": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the link is currently expired",
|
||||
"example": false
|
||||
},
|
||||
"bookingUrl": {
|
||||
"type": "string",
|
||||
"description": "Full booking URL for this private link",
|
||||
"format": "uri",
|
||||
"example": "https://cal.com/d/abc123def456/30min"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"description": "Expiration date for this time-based link",
|
||||
"format": "date-time",
|
||||
"example": "2025-12-31T23:59:59.000Z"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"linkId",
|
||||
"eventTypeId",
|
||||
"isExpired",
|
||||
"bookingUrl",
|
||||
"expiresAt"
|
||||
]
|
||||
},
|
||||
"UsageBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"description": "The private link ID",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event type ID this link belongs to",
|
||||
"example": 123
|
||||
},
|
||||
"isExpired": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the link is currently expired",
|
||||
"example": false
|
||||
},
|
||||
"bookingUrl": {
|
||||
"type": "string",
|
||||
"description": "Full booking URL for this private link",
|
||||
"format": "uri",
|
||||
"example": "https://cal.com/d/abc123def456/30min"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of times this link can be used",
|
||||
"example": 10
|
||||
},
|
||||
"usageCount": {
|
||||
"type": "number",
|
||||
"description": "Current usage count for this link",
|
||||
"example": 3
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"linkId",
|
||||
"eventTypeId",
|
||||
"isExpired",
|
||||
"bookingUrl",
|
||||
"maxUsageCount",
|
||||
"usageCount"
|
||||
]
|
||||
},
|
||||
"CreatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Created private link data (either time-based or usage-based)",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetPrivateLinksOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "Array of private links for the event type (mix of time-based and usage-based)",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdatePrivateLinkBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"description": "New expiration date for time-based links",
|
||||
"example": "2024-12-31T23:59:59.000Z"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "New maximum number of times the link can be used",
|
||||
"example": 10,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Updated private link data (either time-based or usage-based)",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"DeletePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Deleted link information",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Private link deleted successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UserWebhookOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user