Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3fbb672b | ||
|
|
2d5a86774e | ||
|
|
0865104a77 | ||
|
|
1914b37ee4 | ||
|
|
58ef0abb99 | ||
|
|
13ba680a73 | ||
|
|
f40c6da93b | ||
|
|
fc2e81e89a | ||
|
|
293dd1a140 | ||
|
|
b7fda31678 | ||
|
|
94a04f0dc2 | ||
|
|
2338f85dcf | ||
|
|
fbaccc8e5f | ||
|
|
750db38d38 | ||
|
|
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 |
+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
+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
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||
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,7 +191,7 @@ 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()
|
||||
const userAvailabilityService = getUserAvailabilityService();
|
||||
if (!teamId)
|
||||
return userAvailabilityService.getUserAvailability({
|
||||
username,
|
||||
|
||||
@@ -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.283",
|
||||
"@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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
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
|
||||
): Promise<GetPrivateLinksOutput> {
|
||||
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
|
||||
|
||||
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,25 @@
|
||||
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 { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
|
||||
import { PrivateLinksRepository } from "./private-links.repository";
|
||||
import { PrivateLinksInputService } from "./services/private-links-input.service";
|
||||
import { PrivateLinksOutputService } from "./services/private-links-output.service";
|
||||
import { PrivateLinksService } from "./services/private-links.service";
|
||||
|
||||
@Module({
|
||||
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
|
||||
controllers: [EventTypesPrivateLinksController],
|
||||
providers: [
|
||||
PrivateLinksService,
|
||||
PrivateLinksInputService,
|
||||
PrivateLinksOutputService,
|
||||
PrivateLinksRepository,
|
||||
EventTypeOwnershipGuard,
|
||||
],
|
||||
})
|
||||
export class EventTypesPrivateLinksModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
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,46 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import {
|
||||
PrivateLinkOutput,
|
||||
TimeBasedPrivateLinkOutput,
|
||||
UsageBasedPrivateLinkOutput,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type PrivateLinkData = {
|
||||
id: string;
|
||||
eventTypeId: number;
|
||||
isExpired: boolean;
|
||||
bookingUrl: string;
|
||||
expiresAt?: Date | null;
|
||||
maxUsageCount?: number | null;
|
||||
usageCount?: number;
|
||||
};
|
||||
|
||||
@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,125 @@
|
||||
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||
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 { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
@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): 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { GcalModule } from "@/ee/gcal/gcal.module";
|
||||
@@ -35,6 +36,7 @@ import { Module } from "@nestjs/common";
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsModule,
|
||||
RoutingFormsModule,
|
||||
EventTypesPrivateLinksModule,
|
||||
],
|
||||
})
|
||||
export class PlatformEndpointsModule implements NestModule {
|
||||
|
||||
@@ -8,11 +8,15 @@ 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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
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({
|
||||
imports: [PrismaModule],
|
||||
@@ -25,11 +29,15 @@ import { UserAvailabilityService } from "@/lib/services/user-availability.servic
|
||||
PrismaEventTypeRepository,
|
||||
PrismaRoutingFormResponseRepository,
|
||||
PrismaTeamRepository,
|
||||
RedisService,
|
||||
PrismaFeaturesRepository,
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
AvailableSlotsService,
|
||||
UserAvailabilityService
|
||||
UserAvailabilityService,
|
||||
BusyTimesService,
|
||||
FilterHostsService,
|
||||
QualifiedHostsService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
|
||||
@@ -7,11 +7,15 @@ 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()
|
||||
@@ -25,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,
|
||||
@@ -36,9 +46,13 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
selectedSlotRepo: selectedSlotRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
||||
cacheService: new CacheService(featuresRepository),
|
||||
userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository)
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
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";
|
||||
@@ -13,15 +12,13 @@ export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||
oooRepoDependency: PrismaOOORepository,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
|
||||
|
||||
redisService: RedisService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
|
||||
bookingRepo: bookingRepository,
|
||||
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
redisClient: redisService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+52
-2
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
Patch,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
|
||||
type: String,
|
||||
})
|
||||
@Get("/:calendar/event/:eventUid")
|
||||
@Get("/:calendar/events/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiParam({
|
||||
name: "calendar",
|
||||
enum: [GOOGLE_CALENDAR],
|
||||
type: String,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description:
|
||||
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
type: String,
|
||||
})
|
||||
@Patch("/:calendar/events/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Update meeting details in calendar",
|
||||
description: "Updates event information in the specified calendar provider",
|
||||
})
|
||||
async updateCalendarEvent(
|
||||
@Param("calendar") calendar: string,
|
||||
@Param("eventUid") eventUid: string,
|
||||
@Body() updateData: UpdateUnifiedCalendarEventInput
|
||||
): Promise<GetUnifiedCalendarEventOutput> {
|
||||
if (calendar !== GOOGLE_CALENDAR) {
|
||||
throw new BadRequestException("Event updates are currently only available for Google Calendar");
|
||||
}
|
||||
|
||||
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
|
||||
|
||||
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
|
||||
|
||||
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
|
||||
export class UpdateCalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Email address of the attendee",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Display name of the attendee",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
nullable: true,
|
||||
description: "Response status of the attendee",
|
||||
})
|
||||
responseStatus?: CalendarEventResponseStatus | null;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the current user",
|
||||
})
|
||||
self?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the host",
|
||||
})
|
||||
host?: boolean;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateDateTimeWithZone {
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: "string", format: "date-time" })
|
||||
time?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export class UpdateUnifiedCalendarEventInput {
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateDateTimeWithZone)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "Start date and time of the calendar event with timezone information",
|
||||
})
|
||||
start?: UpdateDateTimeWithZone;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateDateTimeWithZone)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "End date and time of the calendar event with timezone information",
|
||||
})
|
||||
end?: UpdateDateTimeWithZone;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Title of the calendar event",
|
||||
})
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: "Detailed description of the calendar event",
|
||||
})
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateCalendarEventAttendee)
|
||||
@ApiPropertyOptional({
|
||||
type: [UpdateCalendarEventAttendee],
|
||||
nullable: true,
|
||||
description:
|
||||
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||
})
|
||||
attendees?: UpdateCalendarEventAttendee[];
|
||||
|
||||
@IsEnum(CalendarEventStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventStatus,
|
||||
enumName: "CalendarEventStatus",
|
||||
nullable: true,
|
||||
description: "Status of the event (accepted, pending, declined, cancelled)",
|
||||
example: CalendarEventStatus.ACCEPTED,
|
||||
})
|
||||
status?: CalendarEventStatus | null;
|
||||
}
|
||||
+34
@@ -225,6 +225,22 @@ export class CalendarEventHost {
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
export class calendarEventOwner {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "Email address of the event host",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the host",
|
||||
})
|
||||
host?: boolean;
|
||||
}
|
||||
|
||||
export class DateTimeWithZone {
|
||||
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
|
||||
})
|
||||
hosts?: CalendarEventHost[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => calendarEventOwner)
|
||||
@ApiPropertyOptional({
|
||||
type: calendarEventOwner,
|
||||
nullable: true,
|
||||
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||
})
|
||||
calendarEventOwner?: calendarEventOwner;
|
||||
|
||||
@IsEnum(CALENDARS)
|
||||
@ApiProperty({
|
||||
enum: CALENDARS,
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
|
||||
|
||||
export const createGoogleCalendarEventFixture = (
|
||||
overrides: Partial<GoogleCalendarEventResponse> = {}
|
||||
): GoogleCalendarEventResponse => {
|
||||
const baseEvent: GoogleCalendarEventResponse = {
|
||||
kind: "calendar#event",
|
||||
etag: "test-etag",
|
||||
id: "test-event-id",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event",
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
summary: "Test Meeting",
|
||||
description: "Test description",
|
||||
creator: {
|
||||
email: "creator@example.com",
|
||||
displayName: "Creator Name",
|
||||
},
|
||||
organizer: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer Name",
|
||||
},
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
iCalUID: "test-ical-uid",
|
||||
sequence: 0,
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
displayName: "Attendee Name",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer Name",
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
},
|
||||
],
|
||||
conferenceData: {
|
||||
conferenceId: "abc-def-ghi",
|
||||
entryPoints: [
|
||||
{
|
||||
entryPointType: "video",
|
||||
uri: "https://meet.google.com/abc-def-ghi",
|
||||
label: "meet.google.com/abc-def-ghi",
|
||||
},
|
||||
],
|
||||
conferenceSolution: {
|
||||
key: {
|
||||
type: "hangoutsMeet",
|
||||
},
|
||||
name: "Google Meet",
|
||||
iconUri:
|
||||
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
|
||||
},
|
||||
},
|
||||
hangoutLink: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return { ...baseEvent, ...overrides };
|
||||
};
|
||||
|
||||
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
|
||||
conferenceData: {
|
||||
conferenceId: "test-conference-id",
|
||||
entryPoints: [
|
||||
{
|
||||
entryPointType: "video",
|
||||
uri: "https://meet.google.com/abc-def-ghi",
|
||||
label: "Google Meet",
|
||||
pin: "123456",
|
||||
regionCode: "US",
|
||||
},
|
||||
{
|
||||
entryPointType: "phone",
|
||||
uri: "tel:+1-555-123-4567",
|
||||
label: "Phone",
|
||||
pin: "789012",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
|
||||
location: "123 Main St, City, State",
|
||||
conferenceData: undefined,
|
||||
hangoutLink: undefined,
|
||||
});
|
||||
|
||||
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
|
||||
hangoutLink: "https://hangouts.google.com/call/abc123",
|
||||
conferenceData: undefined,
|
||||
location: undefined,
|
||||
});
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
CalendarEventStatus,
|
||||
CalendarEventResponseStatus,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
|
||||
import {
|
||||
createGoogleCalendarEventFixture,
|
||||
googleEventWithConferenceData,
|
||||
googleEventWithLocationOnly,
|
||||
googleEventWithHangoutLink,
|
||||
} from "./__fixtures__/google-calendar-event.fixture";
|
||||
import {
|
||||
GoogleCalendarEventOutputPipe,
|
||||
GoogleCalendarEventResponse,
|
||||
} from "./get-calendar-event-details-output-pipe";
|
||||
|
||||
describe("GoogleCalendarEventOutputPipe", () => {
|
||||
let pipe: GoogleCalendarEventOutputPipe;
|
||||
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
sharedGoogleEvent = createGoogleCalendarEventFixture();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new GoogleCalendarEventOutputPipe();
|
||||
});
|
||||
|
||||
describe("transform", () => {
|
||||
it("should transform Google Calendar event to unified format", () => {
|
||||
const result = pipe.transform(sharedGoogleEvent);
|
||||
|
||||
expect(result.id).toBe("test-event-id");
|
||||
expect(result.title).toBe("Test Meeting");
|
||||
expect(result.description).toBe("Test description");
|
||||
expect(result.source).toBe("google");
|
||||
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
|
||||
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
|
||||
expect(result.start.timeZone).toBe("America/New_York");
|
||||
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
|
||||
expect(result.end.timeZone).toBe("America/New_York");
|
||||
expect(result.attendees).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformDateTimeWithZone", () => {
|
||||
it("should transform Google date time to unified format", () => {
|
||||
const googleDateTime = {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](googleDateTime);
|
||||
|
||||
expect(result.time).toBe("2024-01-15T10:00:00Z");
|
||||
expect(result.timeZone).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformAttendeeResponseStatus", () => {
|
||||
it("should transform accepted status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("accepted");
|
||||
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should transform tentative status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("tentative");
|
||||
expect(result).toBe(CalendarEventResponseStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should transform declined status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("declined");
|
||||
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
|
||||
});
|
||||
|
||||
it("should transform needsaction status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
|
||||
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
|
||||
});
|
||||
|
||||
it("should handle case insensitive status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
|
||||
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should handle unknown status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("unknown");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"](undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformEventStatus", () => {
|
||||
it("should transform confirmed status", () => {
|
||||
const result = pipe["transformEventStatus"]("confirmed");
|
||||
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should transform tentative status", () => {
|
||||
const result = pipe["transformEventStatus"]("tentative");
|
||||
expect(result).toBe(CalendarEventStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should transform cancelled status", () => {
|
||||
const result = pipe["transformEventStatus"]("cancelled");
|
||||
expect(result).toBe(CalendarEventStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it("should handle case insensitive status", () => {
|
||||
const result = pipe["transformEventStatus"]("CONFIRMED");
|
||||
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should handle unknown status", () => {
|
||||
const result = pipe["transformEventStatus"]("unknown");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformEventStatus"](undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformOrganizer", () => {
|
||||
it("should return calendarEventOwner organizer exist", () => {
|
||||
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
|
||||
organizer: { email: "organizer@example.com" },
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
displayName: "Regular Attendee",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = pipe.transform(eventWithoutOrganizerAttendees);
|
||||
|
||||
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformLocations", () => {
|
||||
it("should transform conference data entry points", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithConferenceData);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "https://meet.google.com/abc-def-ghi",
|
||||
label: "Google Meet",
|
||||
pin: "123456",
|
||||
regionCode: "US",
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: "phone",
|
||||
url: "tel:+1-555-123-4567",
|
||||
label: "Phone",
|
||||
pin: "789012",
|
||||
regionCode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to location field", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithLocationOnly);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "123 Main St, City, State",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to hangout link", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithHangoutLink);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "https://hangouts.google.com/call/abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when no location data", () => {
|
||||
const eventWithoutLocation = createGoogleCalendarEventFixture({
|
||||
location: undefined,
|
||||
conferenceData: undefined,
|
||||
hangoutLink: undefined,
|
||||
});
|
||||
|
||||
const result = pipe["transformLocations"](eventWithoutLocation);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-6
@@ -3,7 +3,7 @@ import {
|
||||
CalendarEventStatus,
|
||||
DateTimeWithZone,
|
||||
UnifiedCalendarEventOutput,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
import { PipeTransform, Injectable } from "@nestjs/common";
|
||||
|
||||
export interface GoogleCalendarEventResponse {
|
||||
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
|
||||
calendarEvent.locations = this.transformLocations(googleEvent);
|
||||
|
||||
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
|
||||
calendarEvent.attendees = googleEvent.attendees
|
||||
.filter((attendee) => !attendee.organizer)
|
||||
.map((attendee) => {
|
||||
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
name: attendee.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.organizer,
|
||||
host: attendee.organizer,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
|
||||
|
||||
calendarEvent.status = this.transformEventStatus(googleEvent.status);
|
||||
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent);
|
||||
calendarEvent.calendarEventOwner = googleEvent.organizer
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent)
|
||||
|
||||
calendarEvent.source = "google";
|
||||
|
||||
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
|
||||
regionCode: entryPoint.regionCode,
|
||||
};
|
||||
});
|
||||
} else if (googleEvent.location) {
|
||||
return [{ type: "video", url: googleEvent.location }];
|
||||
} else if (googleEvent.hangoutLink) {
|
||||
return [{ type: "video", url: googleEvent.hangoutLink }];
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
UpdateUnifiedCalendarEventInput,
|
||||
UpdateDateTimeWithZone,
|
||||
} from "../inputs/update-unified-calendar-event.input";
|
||||
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
|
||||
// Common interfaces for Google Calendar types
|
||||
interface GoogleCalendarDateTime {
|
||||
dateTime: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
interface GoogleCalendarAttendee {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus: string;
|
||||
organizer?: boolean;
|
||||
}
|
||||
|
||||
interface GoogleCalendarEventInputTransform {
|
||||
transform(
|
||||
updateData: UpdateUnifiedCalendarEventInput,
|
||||
fetchedEvent?: GoogleCalendarEventResponse | null
|
||||
): any;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
|
||||
transform(
|
||||
updateData: UpdateUnifiedCalendarEventInput,
|
||||
): any {
|
||||
const updatePayload: any = {};
|
||||
|
||||
if (updateData.title !== undefined) {
|
||||
updatePayload.summary = updateData.title;
|
||||
}
|
||||
|
||||
if (updateData.description !== undefined) {
|
||||
updatePayload.description = updateData.description;
|
||||
}
|
||||
|
||||
if (updateData.start) {
|
||||
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
|
||||
}
|
||||
|
||||
if (updateData.end) {
|
||||
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
|
||||
}
|
||||
|
||||
if (updateData.attendees !== undefined) {
|
||||
updatePayload.attendees = this.transformAttendees(
|
||||
updateData.attendees,
|
||||
);
|
||||
}
|
||||
|
||||
if (updateData.status !== undefined) {
|
||||
updatePayload.status = this.transformEventStatus(updateData.status);
|
||||
}
|
||||
|
||||
return updatePayload;
|
||||
}
|
||||
|
||||
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
|
||||
return {
|
||||
dateTime: dateTime.time || "",
|
||||
timeZone: dateTime.timeZone || "",
|
||||
};
|
||||
}
|
||||
|
||||
private transformAttendees(
|
||||
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
|
||||
): GoogleCalendarAttendee[] {
|
||||
return inputAttendees.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
displayName: attendee.name,
|
||||
responseStatus: this.transformResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.host,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
|
||||
if (!responseStatus) return "needsAction";
|
||||
|
||||
switch (responseStatus) {
|
||||
case CalendarEventResponseStatus.ACCEPTED:
|
||||
return "accepted";
|
||||
case CalendarEventResponseStatus.PENDING:
|
||||
return "tentative";
|
||||
case CalendarEventResponseStatus.DECLINED:
|
||||
return "declined";
|
||||
case CalendarEventResponseStatus.NEEDS_ACTION:
|
||||
return "needsAction";
|
||||
default:
|
||||
return "needsAction";
|
||||
}
|
||||
}
|
||||
|
||||
private transformEventStatus(status?: CalendarEventStatus | null): string {
|
||||
if (!status) return "confirmed";
|
||||
|
||||
switch (status) {
|
||||
case CalendarEventStatus.ACCEPTED:
|
||||
return "confirmed";
|
||||
case CalendarEventStatus.PENDING:
|
||||
return "tentative";
|
||||
case CalendarEventStatus.CANCELLED:
|
||||
return "cancelled";
|
||||
case CalendarEventStatus.DECLINED:
|
||||
return "cancelled";
|
||||
default:
|
||||
return "confirmed";
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
UpdateUnifiedCalendarEventInput,
|
||||
UpdateDateTimeWithZone,
|
||||
} from "../inputs/update-unified-calendar-event.input";
|
||||
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
|
||||
|
||||
describe("GoogleCalendarEventInputPipe", () => {
|
||||
let pipe: GoogleCalendarEventInputPipe;
|
||||
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
sharedGoogleEvent = {
|
||||
kind: "calendar#event",
|
||||
etag: "test-etag",
|
||||
id: "test-event-id",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event",
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
summary: "Test Meeting",
|
||||
description: "Test description",
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
iCalUID: "test-ical-uid",
|
||||
sequence: 0,
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee1@example.com",
|
||||
displayName: "Attendee One",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "attendee2@example.com",
|
||||
displayName: "Attendee Two",
|
||||
responseStatus: "needsAction",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
},
|
||||
],
|
||||
creator: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
},
|
||||
organizer: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new GoogleCalendarEventInputPipe();
|
||||
});
|
||||
|
||||
describe("transform", () => {
|
||||
it("should transform basic event fields", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
title: "Updated Meeting",
|
||||
description: "Updated description",
|
||||
start: {
|
||||
time: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
time: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
status: CalendarEventStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
summary: "Updated Meeting",
|
||||
description: "Updated description",
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
status: "confirmed",
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle partial updates", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
title: "Only Title Update",
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
summary: "Only Title Update",
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle null description", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformDateTimeWithZone", () => {
|
||||
it("should transform date time with timezone", () => {
|
||||
const input: UpdateDateTimeWithZone = {
|
||||
time: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle empty time and timezone", () => {
|
||||
const input: UpdateDateTimeWithZone = {};
|
||||
|
||||
const expectedOutput = {
|
||||
dateTime: "",
|
||||
timeZone: "",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformResponseStatus", () => {
|
||||
it("should transform ACCEPTED status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
|
||||
expect(result).toBe("accepted");
|
||||
});
|
||||
|
||||
it("should transform PENDING status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
|
||||
expect(result).toBe("tentative");
|
||||
});
|
||||
|
||||
it("should transform DECLINED status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
|
||||
expect(result).toBe("declined");
|
||||
});
|
||||
|
||||
it("should transform NEEDS_ACTION status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformResponseStatus"](null);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
|
||||
it("should handle undefined status", () => {
|
||||
const result = pipe["transformResponseStatus"](undefined);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformEventStatus", () => {
|
||||
it("should transform ACCEPTED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("should transform PENDING status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
|
||||
expect(result).toBe("tentative");
|
||||
});
|
||||
|
||||
it("should transform CANCELLED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
|
||||
expect(result).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should transform DECLINED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
|
||||
expect(result).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformEventStatus"](null);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("should handle undefined status", () => {
|
||||
const result = pipe["transformEventStatus"](undefined);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
|
||||
|
||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
|
||||
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
|
||||
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService {
|
||||
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateEventDetails(
|
||||
eventUid: string,
|
||||
updateData: UpdateUnifiedCalendarEventInput
|
||||
): Promise<GoogleCalendarEventResponse> {
|
||||
const bookingReference =
|
||||
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
|
||||
|
||||
if (!bookingReference) {
|
||||
throw new NotFoundException("Booking reference not found");
|
||||
}
|
||||
|
||||
const ownerUserEmail = bookingReference?.booking?.user?.email;
|
||||
|
||||
const calendar = await this.getAuthorizedCalendarInstance(
|
||||
ownerUserEmail,
|
||||
bookingReference.credential?.key,
|
||||
bookingReference.delegationCredential
|
||||
);
|
||||
|
||||
|
||||
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
|
||||
|
||||
try {
|
||||
const event = await calendar.events.patch({
|
||||
calendarId: bookingReference?.externalCalendarId ?? "primary",
|
||||
eventId: bookingReference?.uid,
|
||||
requestBody: updatePayload,
|
||||
});
|
||||
|
||||
if (!event.data) {
|
||||
throw new NotFoundException("Failed to update meeting");
|
||||
}
|
||||
return event.data as GoogleCalendarEventResponse;
|
||||
} catch (error) {
|
||||
throw new NotFoundException("Failed to update meeting details");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an authorized Google Calendar instance
|
||||
* Tries delegation credentials first, falls back to direct OAuth
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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];
|
||||
|
||||
@@ -8563,6 +8563,69 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/events/{eventUid}": {
|
||||
"patch": {
|
||||
"operationId": "CalUnifiedCalendarsController_updateCalendarEvent",
|
||||
"summary": "Update meeting details in calendar",
|
||||
"description": "Updates event information in the specified calendar provider",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
"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/UpdateUnifiedCalendarEventInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetUnifiedCalendarEventOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Cal Unified Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
@@ -9893,6 +9956,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 +22111,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
]
|
||||
},
|
||||
"recipient": {
|
||||
@@ -22218,7 +22474,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 +22560,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 +22637,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 +22733,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -22543,7 +22803,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 +22882,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 +22961,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 +23151,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 +23242,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 +23324,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 +23406,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 +23485,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 +23569,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -23375,7 +23644,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 +28282,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": {
|
||||
@@ -28351,7 +28845,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Host's response to the invitation",
|
||||
"description": "Response status of the attendee",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -28384,6 +28878,11 @@
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee's attendance is optional"
|
||||
},
|
||||
"host": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the host"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -28422,6 +28921,23 @@
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"calendarEventOwner": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of the event host"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"CalendarSource": {
|
||||
"type": "string",
|
||||
"description": "Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
|
||||
@@ -28518,6 +29034,15 @@
|
||||
"$ref": "#/components/schemas/CalendarEventHost"
|
||||
}
|
||||
},
|
||||
"calendarEventOwner": {
|
||||
"nullable": true,
|
||||
"description": "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/calendarEventOwner"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"example": "google",
|
||||
"$ref": "#/components/schemas/CalendarSource"
|
||||
@@ -28551,6 +29076,91 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdateCalendarEventAttendee": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of the attendee"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name of the attendee"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
},
|
||||
"self": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the current user"
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee's attendance is optional"
|
||||
},
|
||||
"host": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the host"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateUnifiedCalendarEventInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Start date and time of the calendar event with timezone information"
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "End date and time of the calendar event with timezone information"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the calendar event"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detailed description of the calendar event"
|
||||
},
|
||||
"attendees": {
|
||||
"nullable": true,
|
||||
"description": "List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UpdateCalendarEventAttendee"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RequestEmailVerificationInput": {
|
||||
"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;
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateWebhooksListGetByViewer() {
|
||||
revalidateTag("viewer.webhook.getByViewer");
|
||||
export async function revalidateWebhooksList() {
|
||||
revalidatePath("/settings/developer/webhooks");
|
||||
}
|
||||
|
||||
+4
-16
@@ -1,13 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -20,17 +20,6 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/webhooks"
|
||||
);
|
||||
|
||||
const getCachedWebhooksList = unstable_cache(
|
||||
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
|
||||
return await WebhookRepository.getAllWebhooksByUserId({
|
||||
userId,
|
||||
userRole,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -38,9 +27,8 @@ const WebhooksViewServerWrapper = async () => {
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
|
||||
|
||||
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
|
||||
const caller = await createRouterCaller(webhookRouter);
|
||||
const data = await caller.getByViewer();
|
||||
|
||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||
};
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhookById(id: string) {
|
||||
revalidateTag(`viewer.webhook.get:${id}`);
|
||||
}
|
||||
+1
-14
@@ -1,6 +1,5 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||
@@ -16,24 +15,12 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/developer/webhooks/${(await params).id}`
|
||||
);
|
||||
|
||||
const getCachedWebhook = (id?: string) => {
|
||||
const fn = unstable_cache(
|
||||
async () => {
|
||||
return await WebhookRepository.findByWebhookId(id);
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
|
||||
);
|
||||
|
||||
return fn();
|
||||
};
|
||||
|
||||
const Page = async ({ params: _params }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const params = await _params;
|
||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||
|
||||
const webhook = await getCachedWebhook(id);
|
||||
const webhook = await WebhookRepository.findByWebhookId(id);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhookList() {
|
||||
revalidateTag("viewer.webhook.list");
|
||||
}
|
||||
+6
-22
@@ -1,16 +1,10 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -21,25 +15,15 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/webhooks/new"
|
||||
);
|
||||
|
||||
const getCachedWebhooksList = unstable_cache(
|
||||
async ({ userId }: { userId: number }) => {
|
||||
return await WebhookRepository.findWebhooksByFilters({ userId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const appsCaller = await createRouterCaller(appsRouter);
|
||||
const [appsCaller, webhookCaller] = await Promise.all([
|
||||
createRouterCaller(appsRouter),
|
||||
createRouterCaller(webhookRouter),
|
||||
]);
|
||||
|
||||
const [installedApps, webhooks] = await Promise.all([
|
||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||
getCachedWebhooksList({ userId: session.user.id }),
|
||||
webhookCaller.list(),
|
||||
]);
|
||||
|
||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { List } from "@calcom/ui/components/list";
|
||||
@@ -12,10 +16,18 @@ import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||
interface ConnectedAppStepProps {
|
||||
nextStep: () => void;
|
||||
isPageLoading: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep, isPageLoading } = props;
|
||||
const ConnectedVideoStepInner = ({
|
||||
t,
|
||||
setAnyInstalledVideoApps,
|
||||
user,
|
||||
}: {
|
||||
t: TFunction;
|
||||
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}) => {
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
@@ -30,47 +42,59 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
sortByMostPopular: true,
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
const { data } = useMeQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
const metadata = userMetadata.parse(data?.metadata);
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAnyInstalledVideoApps(Boolean(hasAnyInstalledVideoApps));
|
||||
}, [hasAnyInstalledVideoApps, setAnyInstalledVideoApps]);
|
||||
|
||||
if (isPending) {
|
||||
return <StepConnectionLoader />;
|
||||
}
|
||||
|
||||
const result = userMetadata.safeParse(user.metadata);
|
||||
if (!result.success) {
|
||||
return <StepConnectionLoader />;
|
||||
}
|
||||
const { data: metadata } = result;
|
||||
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
|
||||
return (
|
||||
<>
|
||||
{!isPending && (
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={
|
||||
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
{isPending && <StepConnectionLoader />}
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep, isPageLoading, user } = props;
|
||||
const { t } = useLocale();
|
||||
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
|
||||
<Button
|
||||
EndIcon="arrow-right"
|
||||
data-testid="save-video-button"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
@@ -21,8 +22,11 @@ type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const UserProfile = () => {
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
interface UserProfileProps {
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const UserProfile = ({ user }: UserProfileProps) => {
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -21,11 +22,11 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
interface IUserSettingsProps {
|
||||
nextStep: () => void;
|
||||
hideUsername?: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
const { nextStep, user } = props;
|
||||
const { t } = useLocale();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -162,20 +162,20 @@ const OnboardingPage = (props: PageProps) => {
|
||||
<StepCard>
|
||||
<Suspense fallback={<Icon name="loader" />}>
|
||||
{currentStep === "user-settings" && (
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} user={user} />
|
||||
)}
|
||||
{currentStep === "connected-calendar" && (
|
||||
<ConnectedCalendars nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
|
||||
)}
|
||||
|
||||
{currentStep === "connected-video" && (
|
||||
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
|
||||
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} user={user} />
|
||||
)}
|
||||
|
||||
{currentStep === "setup-availability" && (
|
||||
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
|
||||
)}
|
||||
{currentStep === "user-profile" && <UserProfile />}
|
||||
{currentStep === "user-profile" && <UserProfile user={user} />}
|
||||
</Suspense>
|
||||
</StepCard>
|
||||
|
||||
|
||||
@@ -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.11",
|
||||
"version": "5.5.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -78,7 +78,7 @@
|
||||
"@team-plain/typescript-sdk": "^5.9.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",
|
||||
@@ -138,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";
|
||||
|
||||
@@ -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",
|
||||
@@ -3430,6 +3441,35 @@
|
||||
"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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user