Compare commits
46
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 |
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
|
||||
|
||||
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
|
||||
|
||||
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
booker atom: allow toggling org and team info when booking round robin
|
||||
@@ -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
|
||||
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
@@ -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";
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
@@ -34,6 +36,8 @@ import { Module } from "@nestjs/common";
|
||||
AvailableSlotsService,
|
||||
UserAvailabilityService,
|
||||
BusyTimesService,
|
||||
FilterHostsService,
|
||||
QualifiedHostsService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
@@ -29,7 +30,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
userRepository: PrismaUserRepository,
|
||||
redisService: RedisService,
|
||||
featuresRepository: PrismaFeaturesRepository
|
||||
featuresRepository: PrismaFeaturesRepository,
|
||||
qualifiedHostsService: QualifiedHostsService,
|
||||
checkBookingLimitsService: CheckBookingLimitsService,
|
||||
cacheService: CacheService,
|
||||
userAvailabilityService: UserAvailabilityService,
|
||||
busyTimesService: BusyTimesService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -41,15 +47,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
redisClient: redisService,
|
||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
|
||||
cacheService: new CacheService(featuresRepository),
|
||||
userAvailabilityService: new UserAvailabilityService(
|
||||
oooRepoDependency,
|
||||
bookingRepository,
|
||||
eventTypeRepository,
|
||||
redisService
|
||||
),
|
||||
busyTimesService: new BusyTimesService(bookingRepository),
|
||||
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 { 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,23 +9,27 @@ import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const log = logger.getSubLogger({ prefix: ["[SAML callback]"] });
|
||||
const uid = uuid();
|
||||
const log = logger.getSubLogger({ prefix: ["[SAML callback]", `trace: ${uid}`] });
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
const requestData = (await parseRequestData(req)) as SAMLResponsePayload;
|
||||
|
||||
const { redirect_url, error } = await oauthController.samlResponse(requestData);
|
||||
try {
|
||||
const { redirect_url, error } = await oauthController.samlResponse(requestData);
|
||||
|
||||
if (redirect_url) {
|
||||
return NextResponse.redirect(redirect_url, 302);
|
||||
}
|
||||
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} trace:${uid}`
|
||||
);
|
||||
return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 });
|
||||
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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -144,7 +144,7 @@ const PlainContactForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-[1rem] right-[1rem] 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
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.14",
|
||||
"version": "5.5.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -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()})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -3441,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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ 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";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./expects";
|
||||
import { setupAndTeardown } from "./setupAndTeardown";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { expectNoAttemptToGetAvailability } from "../../utils/bookingScenario/ex
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./expects";
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ScenarioData } from "../../utils/bookingScenario/bookingScenario";
|
||||
|
||||
import { describe, expect, vi, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { PeriodType } from "@calcom/prisma/enums";
|
||||
|
||||
import { expectedSlotsForSchedule } from "./expects";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { describe, test, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
import { expect } from "./expects";
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IncomingMessage } from "http";
|
||||
import { describe, test, beforeEach, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { getScheduleSchema, GetScheduleOptions } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
|
||||
@@ -8195,6 +8195,65 @@
|
||||
"tags": ["Cal Unified Calendars"]
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
@@ -9436,6 +9495,190 @@
|
||||
"tags": ["Event Types / Webhooks"]
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
@@ -20169,7 +20412,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
]
|
||||
},
|
||||
"recipient": {
|
||||
@@ -20469,7 +20713,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",
|
||||
@@ -20542,7 +20787,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",
|
||||
@@ -20606,7 +20852,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",
|
||||
@@ -20686,7 +20933,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -20736,7 +20984,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",
|
||||
@@ -20794,7 +21043,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",
|
||||
@@ -20852,7 +21102,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",
|
||||
@@ -21011,7 +21262,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",
|
||||
@@ -21089,7 +21341,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",
|
||||
@@ -21158,7 +21411,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",
|
||||
@@ -21227,7 +21481,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",
|
||||
@@ -21286,7 +21541,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",
|
||||
@@ -21349,7 +21605,8 @@
|
||||
"sms_attendee",
|
||||
"sms_number",
|
||||
"whatsapp_attendee",
|
||||
"whatsapp_number"
|
||||
"whatsapp_number",
|
||||
"cal_ai_phone_call"
|
||||
],
|
||||
"default": "whatsapp_attendee",
|
||||
"description": "Action to perform",
|
||||
@@ -21404,7 +21661,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",
|
||||
@@ -25499,6 +25757,205 @@
|
||||
},
|
||||
"required": ["status", "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": {
|
||||
@@ -25757,7 +26214,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Host's response to the invitation",
|
||||
"description": "Response status of the attendee",
|
||||
"enum": ["accepted", "pending", "declined", "needsAction"]
|
||||
},
|
||||
"CalendarEventAttendee": {
|
||||
@@ -25785,6 +26242,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": ["email"]
|
||||
@@ -25814,6 +26276,21 @@
|
||||
},
|
||||
"required": ["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.",
|
||||
@@ -25906,6 +26383,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"
|
||||
@@ -25927,6 +26413,91 @@
|
||||
},
|
||||
"required": ["status", "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": {
|
||||
|
||||
@@ -61,7 +61,9 @@ UI Component → tRPC Handler → Insights Service → Database Query → Respon
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Add Component to Barrel Export">
|
||||
Update the booking components index file:
|
||||
|
||||
@@ -168,6 +170,7 @@ UI Component → tRPC Handler → Insights Service → Database Query → Respon
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -125,12 +125,45 @@ Along with the props, Availability settings atom accepts custom styles via the *
|
||||
| formClassName | Form which contains the days and toggles |
|
||||
| timezoneSelectClassName | Adds styling to the timezone select component |
|
||||
| subtitlesClassName | Styles the subtitle |
|
||||
| scheduleContainer | Styles the entire schedule component |
|
||||
| scheduleDay | Adds styling to just the day of a particular schedule |
|
||||
| dayRanges | Adds styling to day ranges |
|
||||
| timeRanges | Time ranges in the availability settings can be customized |
|
||||
| labelAndSwitchContainer | Adds styling to label and switches |
|
||||
| overridesModalClassNames | Adds styling to the date overrides modal |
|
||||
| scheduleClassNames | An object for granular styling of schedule components (see detailed table below) |
|
||||
| dateOverrideClassNames | An object for granular styling of date override components (see detailed table below) |
|
||||
|
||||
### scheduleClassNames Object Structure
|
||||
|
||||
The `scheduleClassNames` prop accepts an object with the following structure for granular styling of schedule components:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `schedule` | Styles the entire schedule component |
|
||||
| `scheduleContainer` | Styles the schedule container |
|
||||
| `scheduleDay` | Adds styling to just the day of a particular schedule |
|
||||
| `dayRanges` | Adds styling to day ranges |
|
||||
| `timeRangeField` | Styles the time range input fields |
|
||||
| `labelAndSwitchContainer` | Adds styling to label and switches |
|
||||
| `timePicker` | An object for granular styling of time picker dropdown components (see nested table below) |
|
||||
|
||||
#### timePicker Object Structure (nested within scheduleClassNames)
|
||||
|
||||
The `timePicker` prop accepts an object with the following structure for granular styling of time picker dropdown components. This applies to the time selection dropdowns (e.g., [ 9:00 ]) that users can click to open a dropdown with available times or click to manually enter a time:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main time picker container |
|
||||
| `valueContainer` | Styles the container that holds the selected value |
|
||||
| `value` | Styles the displayed selected time value |
|
||||
| `input` | Styles the input field for manual time entry |
|
||||
| `dropdown` | Styles the dropdown menu with time options |
|
||||
|
||||
### dateOverrideClassNames Object Structure
|
||||
|
||||
The `dateOverrideClassNames` prop accepts an object with the following structure for granular styling of date override components:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main container |
|
||||
| `title` | Styles the title|
|
||||
| `description` | Styles the description |
|
||||
| `button` | Styles the button to add date override |
|
||||
|
||||
<Info>Please ensure all custom classnames are valid [Tailwind CSS](https://tailwindcss.com/) classnames.</Info>
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -47,6 +47,37 @@ Along with the props, calendar settings atom accepts custom styles via the **cla
|
||||
| calendarSettingsCustomClassnames | Adds styling to the entire calendar settings atom |
|
||||
| destinationCalendarSettingsCustomClassnames | Adds styling only to the destination calendar container |
|
||||
| selectedCalendarSettingsCustomClassnames | Adds styling only to the selected calendar container |
|
||||
| selectedCalendarSettingsClassNames | An object for granular styling of selected calendars component (see detailed table below) |
|
||||
| destinationCalendarSettingsClassNames | An object for granular styling of destination calendar component (see detailed table below) |
|
||||
|
||||
### selectedCalendarSettingsClassNames Object Structure
|
||||
|
||||
The `selectedCalendarSettingsClassNames` prop accepts an object with the following nested structure for granular styling of the selected calendars component:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main container of the selected calendars section |
|
||||
| `header.container` | Styles the header container |
|
||||
| `header.title` | Styles the header title |
|
||||
| `header.description` | Styles the header description |
|
||||
| `selectedCalendarsListClassNames.container` | Styles the container that holds all selected calendar items |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.container` | Styles each individual calendar item container |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.header.container` | Styles the header section of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.header.title` | Styles the title of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.header.description` | Styles the description of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.body.container` | Styles the body section of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.body.description` | Styles the body description of each calendar item |
|
||||
| `noSelectedCalendarsMessage` | Styles the message shown when no calendars are connected |
|
||||
|
||||
### destinationCalendarSettingsClassNames Object Structure
|
||||
|
||||
The `destinationCalendarSettingsClassNames` prop accepts an object with the following nested structure for granular styling of the destination calendar component:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main container of the destination calendar section |
|
||||
| `header.title` | Styles the header title text |
|
||||
| `header.description` | Styles the header description text |
|
||||
|
||||
<p></p>
|
||||
Additionally, if you wish to select either the Destination Calendar or the Selected Calendar, we also provide atoms specifically designed for this purpose.
|
||||
|
||||
Binary file not shown.
@@ -97,6 +97,7 @@ export const AppDependencyComponent = ({
|
||||
{t("this_app_requires_connected_account", {
|
||||
appName,
|
||||
dependencyName: dependency.name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -112,7 +113,11 @@ export const AppDependencyComponent = ({
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">
|
||||
{t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })}
|
||||
{t("this_app_requires_connected_account", {
|
||||
appName,
|
||||
dependencyName: dependency.name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -61,6 +61,8 @@ export const DailyLocationType = "integrations:daily";
|
||||
|
||||
export const MeetLocationType = "integrations:google:meet";
|
||||
|
||||
export const MSTeamsLocationType = "integrations:office365_video";
|
||||
|
||||
/**
|
||||
* This isn't an actual location app type. It is a special value that informs to use the Organizer's default conferencing app during booking
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta";
|
||||
import type { DefaultBodyType } from "msw";
|
||||
|
||||
import { MSTeamsLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser";
|
||||
import {
|
||||
CalendarAppDelegationCredentialInvalidGrantError,
|
||||
CalendarAppDelegationCredentialConfigurationError,
|
||||
@@ -254,7 +255,13 @@ export default class Office365CalendarService implements Calendar {
|
||||
body: JSON.stringify(this.translateEvent(event)),
|
||||
});
|
||||
|
||||
const responseJson = await handleErrorsJson<NewCalendarEventType & { iCalUId: string }>(response);
|
||||
const responseJson = await handleErrorsJson<
|
||||
NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
|
||||
>(response);
|
||||
|
||||
if (responseJson?.onlineMeeting?.joinUrl) {
|
||||
responseJson.url = responseJson?.onlineMeeting?.joinUrl;
|
||||
}
|
||||
|
||||
return { ...responseJson, iCalUID: responseJson.iCalUId };
|
||||
} catch (error) {
|
||||
@@ -266,12 +273,28 @@ export default class Office365CalendarService implements Calendar {
|
||||
|
||||
async updateEvent(uid: string, event: CalendarServiceEvent): Promise<NewCalendarEventType> {
|
||||
try {
|
||||
let rescheduledEvent: Event | undefined;
|
||||
if (event.location === MSTeamsLocationType) {
|
||||
// Extract the existing body content to preserve the meeting blob, otherwise it breaks and converts it into non-onlineMeeting
|
||||
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
rescheduledEvent = await handleErrorsJson<Event>(response);
|
||||
}
|
||||
|
||||
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(this.translateEvent(event)),
|
||||
body: JSON.stringify(this.translateEvent(event, rescheduledEvent)),
|
||||
});
|
||||
|
||||
const responseJson = await handleErrorsJson<NewCalendarEventType & { iCalUId: string }>(response);
|
||||
const responseJson = await handleErrorsJson<
|
||||
NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
|
||||
>(response);
|
||||
|
||||
if (responseJson?.onlineMeeting?.joinUrl) {
|
||||
responseJson.url = responseJson?.onlineMeeting?.joinUrl;
|
||||
}
|
||||
|
||||
return { ...responseJson, iCalUID: responseJson.iCalUId };
|
||||
} catch (error) {
|
||||
@@ -401,12 +424,30 @@ export default class Office365CalendarService implements Calendar {
|
||||
});
|
||||
}
|
||||
|
||||
private translateEvent = (event: CalendarServiceEvent) => {
|
||||
private translateEvent = (event: CalendarServiceEvent, rescheduledEvent?: Event) => {
|
||||
const isOnlineMeeting = event.location === MSTeamsLocationType;
|
||||
const isRescheduledOnlineMeeting = rescheduledEvent ? rescheduledEvent.isOnlineMeeting : false;
|
||||
const existingBody =
|
||||
rescheduledEvent?.body?.contentType === "html" ? rescheduledEvent.body.content : undefined;
|
||||
|
||||
let content = "";
|
||||
if (isOnlineMeeting) {
|
||||
if (isRescheduledOnlineMeeting && existingBody) {
|
||||
content = `
|
||||
${getRichDescriptionHTML(event)}<hr>
|
||||
${existingBody}`.trim();
|
||||
} else {
|
||||
content = getRichDescriptionHTML(event);
|
||||
}
|
||||
} else {
|
||||
content = event.calendarDescription;
|
||||
}
|
||||
|
||||
const office365Event: Event = {
|
||||
subject: event.title,
|
||||
body: {
|
||||
contentType: "text",
|
||||
content: event.calendarDescription,
|
||||
contentType: isOnlineMeeting ? "html" : "text",
|
||||
content,
|
||||
},
|
||||
start: {
|
||||
dateTime: dayjs(event.startTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
|
||||
@@ -456,6 +497,17 @@ export default class Office365CalendarService implements Calendar {
|
||||
if (event.hideCalendarEventDetails) {
|
||||
office365Event.sensitivity = "private";
|
||||
}
|
||||
if (isOnlineMeeting) {
|
||||
office365Event.isOnlineMeeting = true;
|
||||
office365Event.allowNewTimeProposals = true;
|
||||
office365Event.onlineMeetingProvider = "teamsForBusiness";
|
||||
// MSTeams sets location as 'Microsoft Teams Meeting' by default, if location is undefined.
|
||||
// For backward compatibility, setting explicitly.
|
||||
office365Event.location =
|
||||
rescheduledEvent && !isRescheduledOnlineMeeting
|
||||
? { displayName: "Microsoft Teams Meeting" }
|
||||
: undefined;
|
||||
}
|
||||
return office365Event;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const metadata = {
|
||||
publisher: "Cal.com",
|
||||
slug: "msteams",
|
||||
dirName: "office365video",
|
||||
dependencies: ["office365-calendar"],
|
||||
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
|
||||
email: "help@cal.com",
|
||||
isOAuth: true,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
}
|
||||
},
|
||||
"dirName": "office365video",
|
||||
"dependencies": ["office365-calendar"],
|
||||
"concurrentMeetings": true,
|
||||
"isOAuth": true
|
||||
}
|
||||
|
||||
@@ -72,7 +72,13 @@ function useBreakPoints() {
|
||||
* It has the the ongoing changes in the form along with enrichedWithUserProfileForm specific data.
|
||||
* So, it can be used to test the form in the test preview dialog without saving the changes even.
|
||||
*/
|
||||
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
|
||||
function SingleForm({
|
||||
form,
|
||||
appUrl,
|
||||
Page,
|
||||
enrichedWithUserProfileForm,
|
||||
permissions,
|
||||
}: SingleFormComponentProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
const [newFormDialogState, setNewFormDialogState] = useState<NewFormDialogState>(null);
|
||||
@@ -153,6 +159,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
|
||||
setShowInfoLostDialog={setShowInfoLostDialog}
|
||||
setIsTestPreviewOpen={setIsTestPreviewOpen}
|
||||
isTestPreviewOpen={isTestPreviewOpen}
|
||||
permissions={permissions}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
|
||||
@@ -59,6 +59,7 @@ const Actions = ({
|
||||
setIsTestPreviewOpen,
|
||||
isTestPreviewOpen,
|
||||
isMobile = false,
|
||||
permissions,
|
||||
}: {
|
||||
form: RoutingFormWithResponseCount;
|
||||
setIsTestPreviewOpen: (value: boolean) => void;
|
||||
@@ -66,6 +67,12 @@ const Actions = ({
|
||||
appUrl: string;
|
||||
isTestPreviewOpen: boolean;
|
||||
isMobile?: boolean;
|
||||
permissions: {
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const formContext = useFormContext<RoutingFormWithResponseCount>();
|
||||
@@ -165,6 +172,7 @@ const Actions = ({
|
||||
className="w-full"
|
||||
type="button"
|
||||
color="destructive"
|
||||
disabled={!permissions.canDelete}
|
||||
StartIcon="trash">
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
@@ -182,6 +190,7 @@ const Actions = ({
|
||||
<Button
|
||||
data-testid={isMobile ? "update-form-mobile" : "update-form"}
|
||||
loading={isSaving}
|
||||
disabled={!permissions.canEdit}
|
||||
type="submit"
|
||||
color="primary">
|
||||
{t("save")}
|
||||
@@ -206,6 +215,7 @@ export function Header({
|
||||
setShowInfoLostDialog,
|
||||
setIsTestPreviewOpen,
|
||||
isTestPreviewOpen,
|
||||
permissions,
|
||||
}: {
|
||||
routingForm: RoutingFormWithResponseCount;
|
||||
isSaving: boolean;
|
||||
@@ -213,6 +223,12 @@ export function Header({
|
||||
setShowInfoLostDialog: (value: boolean) => void;
|
||||
setIsTestPreviewOpen: (value: boolean) => void;
|
||||
isTestPreviewOpen: boolean;
|
||||
permissions: {
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -324,7 +340,8 @@ export function Header({
|
||||
isSaving={isSaving}
|
||||
appUrl={appUrl}
|
||||
isMobile={true}
|
||||
/>
|
||||
permissions={permissions}
|
||||
/>{" "}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -357,6 +374,7 @@ export function Header({
|
||||
isTestPreviewOpen={isTestPreviewOpen}
|
||||
isSaving={isSaving}
|
||||
appUrl={appUrl}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData";
|
||||
@@ -30,16 +34,6 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
};
|
||||
}
|
||||
|
||||
const isFormCreateEditAllowed = (await import("../lib/isFormCreateEditAllowed")).isFormCreateEditAllowed;
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId, targetTeamId: null }))) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/403",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
@@ -105,12 +99,69 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
|
||||
};
|
||||
|
||||
// Get PBAC permissions for team-scoped routing forms
|
||||
let permissions = {
|
||||
canCreate: false,
|
||||
canRead: false,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
};
|
||||
|
||||
if (!form.teamId) {
|
||||
// For personal forms (teamId = null),
|
||||
// check if the form belongs to the current user
|
||||
if (form.userId !== user.id) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
permissions = {
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
};
|
||||
} else {
|
||||
// team-scoped routing form
|
||||
// Get user's role in the team
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId: user.id,
|
||||
teamId: form.teamId,
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
permissions = await getResourcePermissions({
|
||||
userId: user.id,
|
||||
teamId: form.teamId,
|
||||
resource: Resource.RoutingForm,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: { roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
create: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
update: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
delete: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
form: await getSerializableForm({ form: formWithoutProfileInfo }),
|
||||
enrichedWithUserProfileForm: await getSerializableForm({
|
||||
form: enrichFormWithMigrationData(formWithUserInfoProfile),
|
||||
}),
|
||||
permissions: {
|
||||
canCreate: permissions.canCreate,
|
||||
canRead: permissions.canRead,
|
||||
canEdit: permissions.canEdit,
|
||||
canDelete: permissions.canDelete,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,5 @@ export const routingFormsComponents = {
|
||||
"form-edit": () => import("./form-edit/[...appPages]").then((mod) => mod.default),
|
||||
"route-builder": () => import("./route-builder/[...appPages]").then((mod) => mod.default),
|
||||
"routing-link": () => import("./routing-link/[...appPages]").then((mod) => mod.default),
|
||||
reporting: () => import("./reporting/[...appPages]").then((mod) => mod.default),
|
||||
"incomplete-booking": () => import("./incomplete-booking/[...appPages]").then((mod) => mod.default),
|
||||
};
|
||||
|
||||
@@ -348,6 +348,7 @@ const FormEdit = ({
|
||||
|
||||
export default function FormEditPage({
|
||||
appUrl,
|
||||
permissions,
|
||||
...props
|
||||
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
|
||||
return (
|
||||
@@ -356,6 +357,7 @@ export default function FormEditPage({
|
||||
<SingleForm
|
||||
{...props}
|
||||
appUrl={appUrl}
|
||||
permissions={permissions}
|
||||
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
|
||||
<ListLinkItem
|
||||
href={`${appUrl}/form-edit/${form.id}`}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
readOnly={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
|
||||
@@ -320,12 +320,14 @@ export default function IncompleteBookingPage({
|
||||
form,
|
||||
appUrl,
|
||||
enrichedWithUserProfileForm,
|
||||
permissions,
|
||||
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
|
||||
return (
|
||||
<SingleForm
|
||||
form={form}
|
||||
appUrl={appUrl}
|
||||
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
|
||||
permissions={permissions}
|
||||
Page={Page}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useRef, useState, useEffect } from "react";
|
||||
import type {
|
||||
BuilderProps,
|
||||
Config,
|
||||
ImmutableTree,
|
||||
JsonLogicResult,
|
||||
JsonTree,
|
||||
} from "react-awesome-query-builder";
|
||||
import { Builder, Query, Utils as QbUtils } from "react-awesome-query-builder";
|
||||
|
||||
import { downloadAsCsv, sanitizeValue } from "@calcom/lib/csvUtils";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import SingleForm from "../../components/SingleForm";
|
||||
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/getServerSidePropsSingleForm";
|
||||
import {
|
||||
withRaqbSettingsAndWidgets,
|
||||
ConfigFor,
|
||||
} from "../../components/react-awesome-query-builder/config/uiConfig";
|
||||
import type { JsonLogicQuery } from "../../jsonLogicToPrisma";
|
||||
import {
|
||||
getQueryBuilderConfigForFormFields,
|
||||
type FormFieldsQueryBuilderConfigWithRaqbFields,
|
||||
} from "../../lib/getQueryBuilderConfig";
|
||||
|
||||
const Result = ({
|
||||
formName,
|
||||
formId,
|
||||
jsonLogicQuery,
|
||||
}: {
|
||||
formName: string;
|
||||
formId: string;
|
||||
jsonLogicQuery: JsonLogicQuery | null;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const { isPending, status, data, isFetching, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
trpc.viewer.appRoutingForms.report.useInfiniteQuery(
|
||||
{
|
||||
formId: formId,
|
||||
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
|
||||
jsonLogicQuery: jsonLogicQuery?.logic
|
||||
? jsonLogicQuery
|
||||
: {
|
||||
logic: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
const exportQuery = trpc.viewer.appRoutingForms.report.useInfiniteQuery(
|
||||
{
|
||||
limit: 100, // 100 is max
|
||||
formId: formId,
|
||||
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
|
||||
jsonLogicQuery: jsonLogicQuery?.logic
|
||||
? jsonLogicQuery
|
||||
: {
|
||||
logic: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
const buttonInView = useInViewObserver(() => {
|
||||
if (!isFetching && hasNextPage && status === "success") {
|
||||
fetchNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
const headers = useRef<string[] | null>(null);
|
||||
|
||||
if (!isPending && !data) {
|
||||
return <div>Error loading report {error?.message} </div>;
|
||||
}
|
||||
headers.current = (data?.pages && data?.pages[0]?.headers) || headers.current;
|
||||
|
||||
const numberOfRows = data?.pages.reduce((total, page) => total + (page.responses?.length || 0), 0);
|
||||
const downloadButtonDisabled = !numberOfRows || !data || !headers.current || isDownloading;
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
if (downloadButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await exportQuery.refetch();
|
||||
if (!result.data) {
|
||||
throw new Error("There are no routing forms found.");
|
||||
}
|
||||
const allRows = result.data.pages.flatMap((page) =>
|
||||
page.responses.map((response) => `${response.map((value) => sanitizeValue(value)).join(",")}\n`)
|
||||
);
|
||||
let lastPage = result.data.pages[result.data.pages.length - 1];
|
||||
while (lastPage.nextCursor) {
|
||||
const nextPage = await exportQuery.fetchNextPage();
|
||||
if (!nextPage.data) {
|
||||
break;
|
||||
}
|
||||
const latestPageItems = nextPage.data.pages[nextPage.data.pages.length - 1].responses ?? [];
|
||||
allRows.push(
|
||||
...latestPageItems.map((response) => `${response.map((value) => sanitizeValue(value)).join(",")}\n`)
|
||||
);
|
||||
lastPage = nextPage.data.pages[nextPage.data.pages.length - 1];
|
||||
}
|
||||
|
||||
const header = `${(headers.current ?? []).map((value) => sanitizeValue(value)).join(",")}\n`;
|
||||
const csvRaw = header + allRows.join("");
|
||||
const filename = `${formName}_${new Date().toISOString().split("T")[0]}.csv`; // e.g., ${FormName}_2024-10-30.csv
|
||||
downloadAsCsv(csvRaw, filename);
|
||||
} catch (error) {
|
||||
showToast(`Error: ${error}`, "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[2000px] overflow-x-scroll">
|
||||
{!isPending && (
|
||||
<div className="mb-4 inline-block flex min-w-full items-center px-3">
|
||||
<Button
|
||||
disabled={downloadButtonDisabled}
|
||||
StartIcon="file-down"
|
||||
color="secondary"
|
||||
className="mr-3"
|
||||
onClick={() => handleDownload()}>
|
||||
{t("download")}
|
||||
</Button>
|
||||
<div className="text-default text-md">
|
||||
{`${numberOfRows} ${numberOfRows === 1 ? t("row") : t("rows")}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table
|
||||
data-testid="reporting-table"
|
||||
className="border-default bg-subtle mx-3 mb-4 min-w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-md border">
|
||||
<tr
|
||||
data-testid="reporting-header"
|
||||
className="border-default text-default bg-emphasis rounded-md border-b">
|
||||
{headers.current?.map((header, index) => (
|
||||
<th
|
||||
className={classNames(
|
||||
"border-default border-b px-2 py-3 text-left text-base font-medium",
|
||||
index !== (headers.current?.length || 0) - 1 ? "border-r" : ""
|
||||
)}
|
||||
key={index}>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{!isPending &&
|
||||
data?.pages.map((page) => {
|
||||
return page.responses?.map((responses, rowIndex) => {
|
||||
const isLastRow = page.responses.length - 1 === rowIndex;
|
||||
return (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
data-testid="reporting-row"
|
||||
className={classNames(
|
||||
"text-center text-sm",
|
||||
rowIndex % 2 ? "" : "bg-default",
|
||||
isLastRow ? "" : "border-b"
|
||||
)}>
|
||||
{responses.map((r, columnIndex) => {
|
||||
const isLastColumn = columnIndex === responses.length - 1;
|
||||
return (
|
||||
<td
|
||||
className={classNames(
|
||||
"border-default overflow-x-hidden whitespace-pre-line px-2 py-3 text-left",
|
||||
isLastRow ? "" : "border-b",
|
||||
isLastColumn ? "" : "border-r"
|
||||
)}
|
||||
key={columnIndex}>
|
||||
{r}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</table>
|
||||
{isPending ? <div className="text-default p-2">{t("loading")}</div> : ""}
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
ref={buttonInView.ref}
|
||||
loading={isFetchingNextPage}
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => fetchNextPage()}>
|
||||
{hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfigForFormFields>) => {
|
||||
const uuid = QbUtils.uuid();
|
||||
const queryValue: JsonTree = { id: uuid, type: "group" } as JsonTree;
|
||||
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config as unknown as Config);
|
||||
return {
|
||||
state: { tree, config },
|
||||
queryValue,
|
||||
};
|
||||
};
|
||||
|
||||
const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["form"] }) => {
|
||||
const config = getQueryBuilderConfigForFormFields(form, true);
|
||||
const [query, setQuery] = useState(getInitialQuery(config));
|
||||
const [jsonLogicQuery, setJsonLogicQuery] = useState<JsonLogicResult | null>(null);
|
||||
const onChange = (immutableTree: ImmutableTree, config: FormFieldsQueryBuilderConfigWithRaqbFields) => {
|
||||
const jsonTree = QbUtils.getTree(immutableTree);
|
||||
setQuery(() => {
|
||||
const newValue = {
|
||||
state: { tree: immutableTree, config: config },
|
||||
queryValue: jsonTree,
|
||||
};
|
||||
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config as unknown as Config));
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
const renderBuilder = useCallback(
|
||||
(props: BuilderProps) => (
|
||||
<div className="query-builder-container">
|
||||
<div className="query-builder qb-lite">
|
||||
<Builder {...props} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<div className="cal-query-builder">
|
||||
<Query
|
||||
{...withRaqbSettingsAndWidgets({
|
||||
config,
|
||||
configFor: ConfigFor.FormFields,
|
||||
})}
|
||||
value={query.state.tree}
|
||||
onChange={(immutableTree, config) => {
|
||||
onChange(immutableTree, config as unknown as FormFieldsQueryBuilderConfigWithRaqbFields);
|
||||
}}
|
||||
renderBuilder={renderBuilder}
|
||||
/>
|
||||
<Result formName={form.name} formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ReporterWrapper({
|
||||
...props
|
||||
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// It isn't possible to render Reporter without hydration errors if it is rendered on the server.
|
||||
// This is because the RAQB generates some dynamic ids on elements which change b/w client and server.
|
||||
// This is a workaround to render the Reporter on the client only.
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SingleForm
|
||||
{...props}
|
||||
Page={({ form }) => (
|
||||
<div className="route-config bg-default fixed inset-0 w-full overflow-scroll pt-12 ltr:mr-2 rtl:ml-2 sm:pt-0">
|
||||
{isClient && <Reporter form={form} />}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1429,6 +1429,7 @@ export default function RouteBuilder({
|
||||
form,
|
||||
appUrl,
|
||||
enrichedWithUserProfileForm,
|
||||
permissions,
|
||||
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
|
||||
return (
|
||||
<>
|
||||
@@ -1436,6 +1437,7 @@ export default function RouteBuilder({
|
||||
form={form}
|
||||
appUrl={appUrl}
|
||||
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
|
||||
permissions={permissions}
|
||||
Page={Page}
|
||||
/>
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -408,52 +408,6 @@ test.describe("Routing Forms", () => {
|
||||
// Log back in to view form responses.
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto(`/routing-forms/reporting/${routingForm.id}`);
|
||||
|
||||
const headerEls = page.locator("[data-testid='reporting-header'] th");
|
||||
|
||||
// Wait for the headers to be visible(will automaically wait for getting response from backend) along with it the rows are rendered.
|
||||
await headerEls.first().waitFor();
|
||||
|
||||
const numHeaderEls = await headerEls.count();
|
||||
const headers = [];
|
||||
for (let i = 0; i < numHeaderEls; i++) {
|
||||
headers.push(await headerEls.nth(i).innerText());
|
||||
}
|
||||
|
||||
const responses = [];
|
||||
const responseRows = page.locator("[data-testid='reporting-row']");
|
||||
const numResponseRows = await responseRows.count();
|
||||
for (let i = 0; i < numResponseRows; i++) {
|
||||
const rowLocator = responseRows.nth(i).locator("td");
|
||||
const numRowEls = await rowLocator.count();
|
||||
const rowResponses = [];
|
||||
for (let j = 0; j < numRowEls; j++) {
|
||||
rowResponses.push(await rowLocator.nth(j).innerText());
|
||||
}
|
||||
responses.push(rowResponses);
|
||||
}
|
||||
|
||||
expect(headers).toEqual([
|
||||
"Test field",
|
||||
"Multi Select(with Legacy `selectText`)",
|
||||
"Multi Select",
|
||||
"Legacy Select",
|
||||
"Select",
|
||||
// TODO: Find a way to incorporate Routed To and Booked At into the report
|
||||
// @see https://github.com/calcom/cal.com/pull/17229
|
||||
"Routed To",
|
||||
"Assignment Reason",
|
||||
"Booked At",
|
||||
"Submitted At",
|
||||
]);
|
||||
/* Last two columns are "Routed To" and "Booked At" */
|
||||
expect(responses).toEqual([
|
||||
["custom-page", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
|
||||
["external-redirect", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
|
||||
["event-routing", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
|
||||
]);
|
||||
|
||||
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import getConnectedForms from "../lib/getConnectedForms";
|
||||
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
|
||||
import type { TDeleteFormInputSchema } from "./deleteForm.schema";
|
||||
import { checkPermissionOnExistingRoutingForm } from "./permissions";
|
||||
|
||||
interface DeleteFormHandlerOptions {
|
||||
ctx: {
|
||||
@@ -17,11 +18,13 @@ interface DeleteFormHandlerOptions {
|
||||
}
|
||||
export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: input.id, targetTeamId: null }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
await checkPermissionOnExistingRoutingForm({
|
||||
formId: input.id,
|
||||
userId: user.id,
|
||||
permission: "routingForm.delete",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
const areFormsUsingIt = (
|
||||
await getConnectedForms(prisma, {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -16,6 +18,7 @@ import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import type { SerializableForm } from "../types/types";
|
||||
import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
|
||||
import type { TFormMutationInputSchema } from "./formMutation.schema";
|
||||
import { checkPermissionOnExistingRoutingForm } from "./permissions";
|
||||
|
||||
interface FormMutationHandlerOptions {
|
||||
ctx: {
|
||||
@@ -30,10 +33,36 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
||||
const { name, id, description, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
let teamId = input.teamId;
|
||||
const settings = input.settings;
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: id, targetTeamId: teamId }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
|
||||
const existingForm = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingForm) {
|
||||
// Check PBAC permissions for updating routing forms only
|
||||
await checkPermissionOnExistingRoutingForm({
|
||||
formId: existingForm.id,
|
||||
userId: user.id,
|
||||
permission: "routingForm.update",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
} else if (teamId) {
|
||||
// Check PBAC permissions for creating team-scoped routing forms only
|
||||
// Personal forms (teamId = null) are always allowed for the user
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasPermission = await permissionService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
permission: "routingForm.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `You don't have permission to create routing forms for this team`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let { routes: inputRoutes, fields: inputFields } = input;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { TFormQueryInputSchema } from "./formQuery.schema";
|
||||
import { checkPermissionOnExistingRoutingForm } from "./permissions";
|
||||
|
||||
interface FormsHandlerOptions {
|
||||
ctx: {
|
||||
@@ -38,6 +42,13 @@ export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
await checkPermissionOnExistingRoutingForm({
|
||||
formId: input.id,
|
||||
userId: user.id,
|
||||
permission: "routingForm.read",
|
||||
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
return await getSerializableForm({ form });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export async function checkPermissionOnExistingRoutingForm({
|
||||
formId,
|
||||
userId,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
}: {
|
||||
formId: string;
|
||||
userId: number;
|
||||
permission: PermissionString;
|
||||
fallbackRoles: MembershipRole[];
|
||||
}) {
|
||||
// First get the form to check its team context
|
||||
const form = await PrismaRoutingFormRepository.findById(formId, {
|
||||
select: { teamId: true, userId: true },
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Form not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check PBAC permissions for personal-scoped routing forms only
|
||||
if (!form.teamId && form.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to this personal-scoped routing form",
|
||||
});
|
||||
}
|
||||
|
||||
// Check PBAC permissions for team-scoped routing forms only
|
||||
if (form.teamId) {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasPermission = await permissionService.checkPermission({
|
||||
userId: userId,
|
||||
teamId: form.teamId,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `You don't have "${permission}" permission for this routing form`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export type SingleFormComponentProps = {
|
||||
enrichedWithUserProfileForm: inferSSRProps<
|
||||
typeof getServerSidePropsForSingleFormView
|
||||
>["enrichedWithUserProfileForm"];
|
||||
permissions: inferSSRProps<typeof getServerSidePropsForSingleFormView>["permissions"];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const PREMIUM_MONTHLY_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY || "";
|
||||
export const PREMIUM_PLAN_PRODUCT_ID = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID || "";
|
||||
export const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID || "";
|
||||
export const STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID = process.env.STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID || "";
|
||||
|
||||
export const paymentOptions = [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PREMIUM_PLAN_PRODUCT_ID,
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
PREMIUM_MONTHLY_PLAN_PRICE,
|
||||
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID
|
||||
} from "./constants";
|
||||
|
||||
export const getPremiumMonthlyPlanPriceId = (): string => {
|
||||
@@ -20,3 +21,8 @@ export function getPerSeatPlanPrice(): string {
|
||||
export function getPremiumPlanPriceValue() {
|
||||
return "$29/month";
|
||||
}
|
||||
|
||||
|
||||
export function getPhoneNumberMonthlyPriceId(){
|
||||
return STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,14 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.bookerUrl).toBe("https://cal.com/user/test-slug");
|
||||
expect(event.title).toBe("Test Event");
|
||||
expect(event.startTime).toBe(mockStartTime);
|
||||
expect(event.endTime).toBe(mockEndTime);
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.bookerUrl).toBe("https://cal.com/user/test-slug");
|
||||
expect(event.title).toBe("Test Event");
|
||||
expect(event.startTime).toBe(mockStartTime);
|
||||
expect(event.endTime).toBe(mockEndTime);
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with event type details", () => {
|
||||
@@ -50,11 +53,14 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.type).toBe("test-slug");
|
||||
expect(event.description).toBe("Test description");
|
||||
expect(event.eventTypeId).toBe(123);
|
||||
expect(event.hideCalendarNotes).toBe(true);
|
||||
expect(event.hideCalendarEventDetails).toBe(false);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.type).toBe("test-slug");
|
||||
expect(event.description).toBe("Test description");
|
||||
expect(event.eventTypeId).toBe(123);
|
||||
expect(event.hideCalendarNotes).toBe(true);
|
||||
expect(event.hideCalendarEventDetails).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with organizer details", () => {
|
||||
@@ -82,17 +88,20 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.organizer).toEqual({
|
||||
id: 456,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
username: "johndoe",
|
||||
timeZone: "America/New_York",
|
||||
language: {
|
||||
translate: mockTranslate,
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.organizer).toEqual({
|
||||
id: 456,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
username: "johndoe",
|
||||
timeZone: "America/New_York",
|
||||
language: {
|
||||
translate: mockTranslate,
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle nameless organizer", () => {
|
||||
@@ -119,7 +128,10 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.organizer.name).toBe("Nameless");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.organizer.name).toBe("Nameless");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with attendees", () => {
|
||||
@@ -158,7 +170,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withAttendees(attendees)
|
||||
.build();
|
||||
|
||||
expect(event.attendees).toEqual(attendees);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.attendees).toEqual(attendees);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with metadata and responses", () => {
|
||||
@@ -192,10 +207,13 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
expect(event.customInputs).toEqual(customInputs);
|
||||
expect(event.responses).toEqual(responses);
|
||||
expect(event.userFieldsResponses).toEqual(userFieldsResponses);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
expect(event.customInputs).toEqual(customInputs);
|
||||
expect(event.responses).toEqual(responses);
|
||||
expect(event.userFieldsResponses).toEqual(userFieldsResponses);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with location", () => {
|
||||
@@ -216,8 +234,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.conferenceCredentialId).toBe(789);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.conferenceCredentialId).toBe(789);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with destination calendar", () => {
|
||||
@@ -229,6 +250,8 @@ describe("CalendarEventBuilder", () => {
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
credentialId: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
};
|
||||
@@ -247,7 +270,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withDestinationCalendar([destinationCalendar])
|
||||
.build();
|
||||
|
||||
expect(event.destinationCalendar).toEqual([destinationCalendar]);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.destinationCalendar).toEqual([destinationCalendar]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with identifiers", () => {
|
||||
@@ -268,8 +294,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.iCalSequence).toBe(2);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.iCalSequence).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with confirmation settings", () => {
|
||||
@@ -290,8 +319,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeUndefined();
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should set oneTimePassword to null when isConfirmedByDefault is true", () => {
|
||||
@@ -312,8 +344,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeNull();
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with platform variables", () => {
|
||||
@@ -336,10 +371,13 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.platformRescheduleUrl).toBe("https://platform.com/reschedule");
|
||||
expect(event.platformCancelUrl).toBe("https://platform.com/cancel");
|
||||
expect(event.platformBookingUrl).toBe("https://platform.com/booking");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.platformRescheduleUrl).toBe("https://platform.com/reschedule");
|
||||
expect(event.platformCancelUrl).toBe("https://platform.com/cancel");
|
||||
expect(event.platformBookingUrl).toBe("https://platform.com/booking");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with apps status", () => {
|
||||
@@ -376,7 +414,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withAppsStatus(appsStatus)
|
||||
.build();
|
||||
|
||||
expect(event.appsStatus).toEqual(appsStatus);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.appsStatus).toEqual(appsStatus);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with video call data", () => {
|
||||
@@ -401,7 +442,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withVideoCallData(videoCallData)
|
||||
.build();
|
||||
|
||||
expect(event.videoCallData).toEqual(videoCallData);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.videoCallData).toEqual(videoCallData);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with team information", () => {
|
||||
@@ -435,7 +479,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withTeam(team)
|
||||
.build();
|
||||
|
||||
expect(event.team).toEqual(team);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.team).toEqual(team);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with recurring event information", () => {
|
||||
@@ -459,7 +506,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withRecurring(recurringEvent)
|
||||
.build();
|
||||
|
||||
expect(event.recurringEvent).toEqual(recurringEvent);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.recurringEvent).toEqual(recurringEvent);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with attendee seat ID", () => {
|
||||
@@ -477,7 +527,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withAttendeeSeatId("seat-123")
|
||||
.build();
|
||||
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with UID", () => {
|
||||
@@ -495,7 +548,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withUid("booking-uid-123")
|
||||
.build();
|
||||
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with one-time password", () => {
|
||||
@@ -513,7 +569,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withOneTimePassword("otp123")
|
||||
.build();
|
||||
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with recurring event ID", () => {
|
||||
@@ -531,9 +590,12 @@ describe("CalendarEventBuilder", () => {
|
||||
.withRecurringEventId("recurring-123")
|
||||
.build();
|
||||
|
||||
expect(event.existingRecurringEvent).toEqual({
|
||||
recurringEventId: "recurring-123",
|
||||
});
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.existingRecurringEvent).toEqual({
|
||||
recurringEventId: "recurring-123",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should create a complete calendar event with all properties", () => {
|
||||
@@ -597,6 +659,8 @@ describe("CalendarEventBuilder", () => {
|
||||
userId: 1,
|
||||
eventTypeId: 123,
|
||||
credentialId: 1,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
},
|
||||
@@ -644,26 +708,29 @@ describe("CalendarEventBuilder", () => {
|
||||
.build();
|
||||
|
||||
// Test that all properties are set correctly
|
||||
expect(event.title).toBe("Complete Test Event");
|
||||
expect(event.type).toBe("complete-test");
|
||||
expect(event.organizer.name).toBe("John Doe");
|
||||
expect(event.attendees).toHaveLength(1);
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.appsStatus).toHaveLength(1);
|
||||
expect(event.videoCallData?.url).toBe("https://meet.example.com/123");
|
||||
expect(event.team?.name).toBe("Engineering Team");
|
||||
expect(event.recurringEvent?.count).toBe(5);
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.title).toBe("Complete Test Event");
|
||||
expect(event.type).toBe("complete-test");
|
||||
expect(event.organizer.name).toBe("John Doe");
|
||||
expect(event.attendees).toHaveLength(1);
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.appsStatus).toHaveLength(1);
|
||||
expect(event.videoCallData?.url).toBe("https://meet.example.com/123");
|
||||
expect(event.team?.name).toBe("Engineering Team");
|
||||
expect(event.recurringEvent?.count).toBe(5);
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw an error when building without required fields", () => {
|
||||
it("should return null when building without required fields", () => {
|
||||
const builder = new CalendarEventBuilder();
|
||||
expect(() => builder.build()).toThrow("Missing required fields for calendar event");
|
||||
expect(builder.build()).toBeNull();
|
||||
});
|
||||
|
||||
it("should create an event from an existing event", () => {
|
||||
@@ -684,8 +751,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.title).toBe("Updated Event");
|
||||
expect(event.type).toBe("existing-type");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.title).toBe("Updated Event");
|
||||
expect(event.type).toBe("existing-type");
|
||||
}
|
||||
});
|
||||
|
||||
it("should propagate disableCancelling and disableRescheduling", () => {
|
||||
@@ -706,7 +776,10 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.disableCancelling).toBe(true);
|
||||
expect(event.disableRescheduling).toBe(true);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.disableCancelling).toBe(true);
|
||||
expect(event.disableRescheduling).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ export class CalendarEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): CalendarEvent {
|
||||
build(): CalendarEvent | null {
|
||||
// Validate required fields
|
||||
if (
|
||||
!this.event.startTime ||
|
||||
@@ -275,7 +275,7 @@ export class CalendarEventBuilder {
|
||||
!this.event.bookerUrl ||
|
||||
!this.event.title
|
||||
) {
|
||||
throw new Error("Missing required fields for calendar event");
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.event as CalendarEvent;
|
||||
|
||||
@@ -81,6 +81,7 @@ const BookerComponent = ({
|
||||
timeZones,
|
||||
eventMetaChildren,
|
||||
roundRobinHideOrgAndTeam,
|
||||
showNoAvailabilityDialog,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
@@ -417,6 +418,7 @@ const BookerComponent = ({
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -445,6 +447,7 @@ const BookerComponent = ({
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</BookerSection>
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DatePicker = ({
|
||||
isLoading,
|
||||
classNames,
|
||||
scrollToTimeSlots,
|
||||
showNoAvailabilityDialog,
|
||||
}: {
|
||||
event: {
|
||||
data?: {
|
||||
@@ -72,6 +73,7 @@ export const DatePicker = ({
|
||||
isLoading?: boolean;
|
||||
classNames?: DatePickerClassNames;
|
||||
scrollToTimeSlots?: () => void;
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
}) => {
|
||||
const { i18n } = useLocale();
|
||||
const [month, selectedDate, layout] = useBookerStore(
|
||||
@@ -148,6 +150,7 @@ export const DatePicker = ({
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
periodData={periodData}
|
||||
isCompact={isCompact}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface BookerProps {
|
||||
hashedLink?: string | null;
|
||||
isInstantMeeting?: boolean;
|
||||
teamMemberEmail?: string | null;
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user