Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87d2ec9e75 | ||
|
|
226686d4dd | ||
|
|
518ab82e3b | ||
|
|
532b78a9b3 | ||
|
|
1251091300 | ||
|
|
ce1459011a | ||
|
|
e1bdac077a | ||
|
|
d666f2926c | ||
|
|
745f0b0872 | ||
|
|
65f50e9f25 | ||
|
|
54162f8568 | ||
|
|
0f4b9b2a6e | ||
|
|
70b4e3a9c6 | ||
|
|
ab44d5b92c | ||
|
|
bed08c1e4f | ||
|
|
1e0f7f433f | ||
|
|
0cc7a9fc22 | ||
|
|
b03b878199 | ||
|
|
7a560cf09b | ||
|
|
58ad441223 | ||
|
|
d8de919acb | ||
|
|
d699b33dcb | ||
|
|
c071df4cd6 | ||
|
|
219b13d053 | ||
|
|
5e09281974 | ||
|
|
b66d36a98e | ||
|
|
68ce9524eb | ||
|
|
357a42b512 | ||
|
|
33d7da88bf | ||
|
|
cd888be61e | ||
|
|
06231ec299 | ||
|
|
e38705c3e8 | ||
|
|
5c95268d1a | ||
|
|
b310eb1a7e | ||
|
|
051510fa36 | ||
|
|
7d1226b823 | ||
|
|
7732a22c49 | ||
|
|
2a41cd0770 | ||
|
|
a574e5e60f | ||
|
|
0b6508379b | ||
|
|
f6930df87d | ||
|
|
7d4e8e2210 | ||
|
|
50eeb712e3 | ||
|
|
75edc4539c | ||
|
|
def87ea84a | ||
|
|
4153e5b2d5 | ||
|
|
8c2f6ae145 | ||
|
|
78ea4d2f68 | ||
|
|
e7c638c1e4 | ||
|
|
c28fab4fe2 | ||
|
|
e8a02554b6 | ||
|
|
4017c0e28f | ||
|
|
d72f7f8a42 | ||
|
|
3a4ebadbf3 | ||
|
|
765d601ff9 | ||
|
|
3a22fdb428 | ||
|
|
93580ac4bc | ||
|
|
b15ad6f233 | ||
|
|
56e59ab3e1 | ||
|
|
ee796933ee | ||
|
|
d2f76edfd6 | ||
|
|
822f0204a6 | ||
|
|
9770aff153 | ||
|
|
723d0ddec2 | ||
|
|
60b61eb625 | ||
|
|
5d2d99006b | ||
|
|
5d01eb2f38 | ||
|
|
ac3fb0a1cd | ||
|
|
1730ef7d5d | ||
|
|
9e371ded90 | ||
|
|
15098bdd1e | ||
|
|
625a7ec180 |
@@ -19,6 +19,11 @@
|
||||
# instructions. This environment variable is deprecated although still supported for backward compatibility.
|
||||
# @see https://console.cal.com
|
||||
CALCOM_LICENSE_KEY=
|
||||
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
||||
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
||||
CAL_SIGNATURE_TOKEN=
|
||||
# The route to the Cal.com License API
|
||||
CALCOM_PRIVATE_API_ROUTE=
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
|
||||
@@ -37,12 +37,3 @@ jobs:
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link.
|
||||
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ To develop locally:
|
||||
|
||||
- Duplicate `.env.example` to `.env`.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
6. Setup Node
|
||||
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// reduce sample rate to 10% on production
|
||||
tracesSampleRate: process.env.NODE_ENV !== "production" ? 1.0 : 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,7 @@ export const withValidQueryIdTransformParseInt = withValidation({
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
|
||||
export const getTranscriptFromRecordingId = schemaQueryIdParseInt.extend({
|
||||
recordingId: z.string(),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ const { withAxiom } = require("next-axiom");
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/core",
|
||||
@@ -87,12 +92,12 @@ const nextConfig = {
|
||||
};
|
||||
|
||||
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
nextConfig["sentry"] = {
|
||||
autoInstrumentServerFunctions: true,
|
||||
hideSourceMaps: true,
|
||||
};
|
||||
|
||||
plugins.push(withSentryConfig);
|
||||
plugins.push((nextConfig) =>
|
||||
withSentryConfig(nextConfig, {
|
||||
autoInstrumentServerFunctions: true,
|
||||
hideSourceMaps: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@sentry/nextjs": "^7.73.0",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.4",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import {
|
||||
getTranscriptsAccessLinkFromRecordingId,
|
||||
checkIfRoomNameMatchesInRecording,
|
||||
} from "@calcom/core/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /bookings/{id}/transcripts/{recordingId}:
|
||||
* get:
|
||||
* summary: Find all Cal video transcripts of that recording
|
||||
* operationId: getTranscriptsByRecordingId
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* schema:
|
||||
* type: integer
|
||||
* required: true
|
||||
* description: ID of the booking for which transcripts need to be fetched.
|
||||
* - in: path
|
||||
* name: recordingId
|
||||
* schema:
|
||||
* type: string
|
||||
* required: true
|
||||
* description: ID of the recording(daily.co recording id) for which transcripts need to be fetched.
|
||||
* - in: query
|
||||
* name: apiKey
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* tags:
|
||||
* - bookings
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* 401:
|
||||
* description: Authorization information is missing or invalid.
|
||||
* 404:
|
||||
* description: Booking was not found
|
||||
*/
|
||||
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id, recordingId } = getTranscriptFromRecordingId.parse(query);
|
||||
|
||||
await checkIfRecordingBelongsToBooking(id, recordingId);
|
||||
|
||||
const transcriptsAccessLinks = await getTranscriptsAccessLinkFromRecordingId(recordingId);
|
||||
|
||||
return transcriptsAccessLinks;
|
||||
}
|
||||
|
||||
const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { references: true },
|
||||
});
|
||||
|
||||
if (!booking)
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `No Booking found with booking id ${bookingId}`,
|
||||
});
|
||||
|
||||
const roomName =
|
||||
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
|
||||
undefined;
|
||||
|
||||
if (!roomName)
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `No Booking Reference with Daily Video found with booking id ${bookingId}`,
|
||||
});
|
||||
|
||||
const canUserAccessRecordingId = await checkIfRoomNameMatchesInRecording(roomName, recordingId);
|
||||
if (!canUserAccessRecordingId) {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: `This Recording Id ${recordingId} does not belong to booking ${bookingId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
|
||||
import authMiddleware from "../../_auth-middleware";
|
||||
|
||||
export default withMiddleware()(
|
||||
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await authMiddleware(req);
|
||||
return defaultHandler({
|
||||
GET: import("./_get"),
|
||||
})(req, res);
|
||||
})
|
||||
);
|
||||
@@ -7,7 +7,12 @@ import { schemaUserReadPublic } from "~/lib/validations/user";
|
||||
|
||||
async function handler({ userId }: NextApiRequest) {
|
||||
const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
return { user: schemaUserReadPublic.parse(data) };
|
||||
return {
|
||||
user: schemaUserReadPublic.parse({
|
||||
...data,
|
||||
avatar: data.avatarUrl,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -45,7 +45,10 @@ export async function getHandler(req: NextApiRequest) {
|
||||
if (!isSystemWideAdmin && query.userId !== req.userId)
|
||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
const data = await prisma.user.findUnique({ where: { id: query.userId } });
|
||||
const user = schemaUserReadPublic.parse(data);
|
||||
const user = schemaUserReadPublic.parse({
|
||||
...data,
|
||||
avatar: data?.avatarUrl,
|
||||
});
|
||||
return { user };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId";
|
||||
import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user";
|
||||
@@ -101,7 +103,8 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
if (!isSystemWideAdmin && query.userId !== req.userId)
|
||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
|
||||
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
||||
const { avatar, ...body }: { avatar?: string | undefined } & Prisma.UserUpdateInput =
|
||||
await schemaUserEditBodyParams.parseAsync(req.body);
|
||||
// disable role or branding changes unless admin.
|
||||
if (!isSystemWideAdmin) {
|
||||
if (body.role) body.role = undefined;
|
||||
@@ -119,6 +122,14 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
message: "Bad request: Invalid default schedule id",
|
||||
});
|
||||
}
|
||||
|
||||
if (avatar) {
|
||||
body.avatarUrl = await uploadAvatar({
|
||||
userId: query.userId,
|
||||
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
|
||||
});
|
||||
}
|
||||
|
||||
const data = await prisma.user.update({
|
||||
where: { id: query.userId },
|
||||
data: body,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { init as SentryInit } from "@sentry/nextjs";
|
||||
|
||||
SentryInit({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { init as SentryInit } from "@sentry/nextjs";
|
||||
|
||||
SentryInit({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import prismaMock from "../../../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
getTranscriptsAccessLinkFromRecordingId,
|
||||
checkIfRoomNameMatchesInRecording,
|
||||
} from "@calcom/core/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
import authMiddleware from "../../../../../../pages/api/bookings/[id]/_auth-middleware";
|
||||
import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[recordingId]/_get";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/core/videoClient", () => {
|
||||
return {
|
||||
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
||||
checkIfRoomNameMatchesInRecording: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
|
||||
return {
|
||||
getAccessibleUsers: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const mockGetTranscripts = () => {
|
||||
const downloadLinks = [{ format: "json", link: "https://URL1" }];
|
||||
|
||||
vi.mocked(getTranscriptsAccessLinkFromRecordingId).mockResolvedValue(downloadLinks);
|
||||
vi.mocked(checkIfRoomNameMatchesInRecording).mockResolvedValue(true);
|
||||
|
||||
return downloadLinks;
|
||||
};
|
||||
|
||||
const recordingId = "abc-xyz";
|
||||
|
||||
describe("GET /api/bookings/[id]/transcripts/[recordingId]", () => {
|
||||
test("Returns transcripts if user is system-wide admin", async () => {
|
||||
const adminUserId = 1;
|
||||
const userId = 2;
|
||||
|
||||
const bookingId = 1111;
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(
|
||||
buildBooking({
|
||||
id: bookingId,
|
||||
userId,
|
||||
references: [
|
||||
{
|
||||
id: 1,
|
||||
type: "daily_video",
|
||||
uid: "17OHkCH53pBa03FhxMbw",
|
||||
meetingId: "17OHkCH53pBa03FhxMbw",
|
||||
meetingPassword: "password",
|
||||
meetingUrl: "https://URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const mockedTranscripts = mockGetTranscripts();
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: bookingId,
|
||||
recordingId,
|
||||
},
|
||||
});
|
||||
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = adminUserId;
|
||||
|
||||
await authMiddleware(req);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
|
||||
});
|
||||
|
||||
test("Allows GET transcripts when user is org-wide admin", async () => {
|
||||
const adminUserId = 1;
|
||||
const memberUserId = 10;
|
||||
const bookingId = 3333;
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(
|
||||
buildBooking({
|
||||
id: bookingId,
|
||||
userId: memberUserId,
|
||||
references: [
|
||||
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
body: {},
|
||||
query: {
|
||||
id: bookingId,
|
||||
recordingId,
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = adminUserId;
|
||||
req.isOrganizationOwnerOrAdmin = true;
|
||||
mockGetTranscripts();
|
||||
|
||||
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
|
||||
|
||||
await authMiddleware(req);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -2,5 +2,6 @@ const rootConfig = require("../../../packages/config/prettier-preset");
|
||||
|
||||
module.exports = {
|
||||
...rootConfig,
|
||||
importOrder: ["^./instrument", ...rootConfig.importOrder],
|
||||
importOrderParserPlugins: ["typescript", "decorators-legacy"],
|
||||
};
|
||||
|
||||
@@ -25,11 +25,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-libraries": "0.0.2",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -38,8 +40,7 @@
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@sentry/node": "^7.86.0",
|
||||
"@sentry/tracing": "^7.86.0",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"next-auth": "^4.22.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"querystring": "^0.2.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^15.3.0",
|
||||
|
||||
@@ -50,8 +50,6 @@ import { AppController } from "./app.controller";
|
||||
EndpointsModule,
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
//register prefix for all routes in EndpointsModule
|
||||
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
+36
-15
@@ -1,26 +1,43 @@
|
||||
import { getEnv } from "@/env";
|
||||
import "./instrument";
|
||||
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { SentryFilter } from "@/filters/sentry-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import type { ValidationError } from "@nestjs/common";
|
||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { HttpAdapterHost } from "@nestjs/core";
|
||||
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import { Request } from "express";
|
||||
import helmet from "helmet";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import {
|
||||
API_VERSIONS,
|
||||
VERSION_2024_04_15,
|
||||
API_VERSIONS_ENUM,
|
||||
CAL_API_VERSION_HEADER,
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
|
||||
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
prefix: "v",
|
||||
defaultVersion: "1",
|
||||
type: VersioningType.CUSTOM,
|
||||
extractor: (request: unknown) => {
|
||||
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
|
||||
console.log("asap header headerVersion", headerVersion);
|
||||
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
|
||||
console.log("asap return header headerVersion", headerVersion);
|
||||
return headerVersion;
|
||||
}
|
||||
return VERSION_2024_04_15;
|
||||
},
|
||||
defaultVersion: VERSION_2024_04_15,
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
@@ -28,7 +45,15 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
|
||||
allowedHeaders: [
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
CAL_API_VERSION_HEADER,
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Origin",
|
||||
],
|
||||
maxAge: 86_400,
|
||||
});
|
||||
|
||||
@@ -46,15 +71,11 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
})
|
||||
);
|
||||
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: getEnv("SENTRY_DSN"),
|
||||
});
|
||||
}
|
||||
|
||||
// Exception filters, new filters go at the bottom, keep the order
|
||||
const { httpAdapter } = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new SentryFilter(httpAdapter));
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
|
||||
}
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.useGlobalFilters(new ZodExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
@@ -2,11 +2,10 @@ import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
@@ -20,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.2";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
@@ -29,7 +28,7 @@ describe("Bookings Endpoints", () => {
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
@@ -43,7 +42,7 @@ describe("Bookings Endpoints", () => {
|
||||
const moduleRef = await withAccessTokenAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, SchedulesModule],
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
@@ -55,13 +54,13 @@ describe("Bookings Endpoints", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService>(SchedulesService);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput = {
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -37,14 +38,14 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
handleNewBooking,
|
||||
BookingResponse,
|
||||
HttpError,
|
||||
handleNewRecurringBooking,
|
||||
handleInstantMeeting,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -72,8 +73,8 @@ const DEFAULT_PLATFORM_PARAMS = {
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/bookings",
|
||||
version: "2",
|
||||
path: "/v2/bookings",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
|
||||
import {
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
GOOGLE_CALENDAR_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
|
||||
class CalendarsServiceMock {
|
||||
async getCalendars() {
|
||||
return {
|
||||
connectedCalendars: [
|
||||
{
|
||||
integration: {
|
||||
type: "google_calendar",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Platform Calendars Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
|
||||
let user: User;
|
||||
let office365Credentials: Credential;
|
||||
let googleCalendarCredentials: Credential;
|
||||
let accessTokenSecret: string;
|
||||
let refreshTokenSecret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.overrideProvider(CalendarsService)
|
||||
.useClass(CalendarsServiceMock)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
|
||||
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
|
||||
accessTokenSecret = tokens.accessToken;
|
||||
refreshTokenSecret = tokens.refreshToken;
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: [CLIENT_REDIRECT_URI],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(oAuthClient).toBeDefined();
|
||||
expect(accessTokenSecret).toBeDefined();
|
||||
expect(refreshTokenSecret).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer invalid_access_token`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
const data = response.body.data;
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
const data = response.body.data;
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/random-calendar/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
|
||||
await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
|
||||
office365Credentials = await credentialsRepositoryFixture.create(
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
{},
|
||||
user.id,
|
||||
OFFICE_365_CALENDAR_ID
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
|
||||
googleCalendarCredentials = await credentialsRepositoryFixture.create(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
{},
|
||||
user.id,
|
||||
GOOGLE_CALENDAR_ID
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
||||
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Request } from "express";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
export interface CalendarApp {
|
||||
save(state: string, code: string, origin: string): Promise<{ url: string }>;
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface OAuthCalendarApp extends CalendarApp {
|
||||
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule],
|
||||
providers: [CredentialsRepository, CalendarsService],
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
OutlookService,
|
||||
GoogleCalendarService,
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
],
|
||||
controllers: [CalendarsController],
|
||||
exports: [CalendarsService],
|
||||
})
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Req,
|
||||
Param,
|
||||
Headers,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
import { APPS_READ } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/calendars",
|
||||
version: "2",
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@DocsTags("Calendars")
|
||||
export class CalendarsController {
|
||||
constructor(private readonly calendarsService: CalendarsService) {}
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService
|
||||
) {}
|
||||
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@Get("/busy-times")
|
||||
async getBusyTimes(
|
||||
@Query() queryParams: CalendarBusyTimesInput,
|
||||
@@ -47,6 +71,7 @@ export class CalendarsController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(AccessTokenGuard)
|
||||
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
||||
const calendars = await this.calendarsService.getCalendars(userId);
|
||||
|
||||
@@ -55,4 +80,74 @@ export class CalendarsController {
|
||||
data: calendars,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("calendar") calendar: string,
|
||||
@Query("redir") redir?: string | null
|
||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.connect(authorization, req, redir ?? "");
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/save")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Redirect(undefined, 301)
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir } = z
|
||||
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
|
||||
.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
});
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@Permissions([APPS_READ])
|
||||
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.check(userId);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.check(userId);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import {
|
||||
CredentialsRepository,
|
||||
CredentialsWithUserEmail,
|
||||
@@ -11,21 +12,27 @@ import {
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries";
|
||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries";
|
||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsService {
|
||||
private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
@@ -110,4 +117,24 @@ export class CalendarsService {
|
||||
});
|
||||
return composedSelectedCalendars;
|
||||
}
|
||||
|
||||
async getAppKeys(appName: string) {
|
||||
const app = await this.appsRepository.getAppBySlug(appName);
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
const CALENDAR_SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
private logger = new Logger("GcalService");
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
async getOAuthClient(redirectUri: string) {
|
||||
this.logger.log("Getting Google Calendar OAuth Client");
|
||||
const app = await this.appsRepository.getAppBySlug("google-calendar");
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
||||
return oAuth2Client;
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
}
|
||||
|
||||
if (gcalCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid google oauth credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const googleCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
if (!googleCalendar) {
|
||||
throw new UnauthorizedException("Google Calendar not connected.");
|
||||
}
|
||||
if (googleCalendar.error?.message) {
|
||||
throw new UnauthorizedException(googleCalendar.error?.message);
|
||||
}
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
async saveCalendarCredentialsAndRedirect(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
) {
|
||||
// User chose not to authorize your app or didn't authorize your app
|
||||
// redirect directly without oauth code
|
||||
if (!code) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
|
||||
if (primaryCal?.id) {
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return { url: redir || origin };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
SUCCESS_STATUS,
|
||||
OFFICE_365_CALENDAR,
|
||||
OFFICE_365_CALENDAR_ID,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class OutlookService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
const params = {
|
||||
response_type: "code",
|
||||
scope: scopes.join(" "),
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!office365CalendarCredentials) {
|
||||
throw new BadRequestException("Credentials for office_365_calendar not found.");
|
||||
}
|
||||
|
||||
if (office365CalendarCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid office 365 calendar credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const office365Calendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
if (!office365Calendar) {
|
||||
throw new UnauthorizedException("Office 365 calendar not connected.");
|
||||
}
|
||||
if (office365Calendar.error?.message) {
|
||||
throw new UnauthorizedException(office365Calendar.error?.message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
async getOAuthCredentials(code: string) {
|
||||
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
|
||||
const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
Object.keys(payload)
|
||||
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
|
||||
.join("&");
|
||||
|
||||
const body = toUrlEncoded({
|
||||
client_id,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
scope: scopes.join(" "),
|
||||
redirect_uri: this.redirectUri,
|
||||
client_secret,
|
||||
});
|
||||
|
||||
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
async getDefaultCalendar(accessToken: string): Promise<OfficeCalendar> {
|
||||
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const responseBody = await response.json();
|
||||
|
||||
return responseBody as OfficeCalendar;
|
||||
}
|
||||
|
||||
async saveCalendarCredentialsAndRedirect(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
) {
|
||||
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||
if (!code) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
|
||||
|
||||
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
|
||||
|
||||
if (defaultCalendar?.id) {
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
office365OAuthCredentials,
|
||||
ownerId
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
defaultCalendar.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: redir || origin,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
@@ -204,6 +204,16 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 if param event type id is null", async () => {
|
||||
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||
|
||||
const body: UpdateEventTypeInput = {
|
||||
locations,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer()).patch(`/api/v2/event-types/null`).send(body).expect(400);
|
||||
});
|
||||
|
||||
it("should update event type locations", async () => {
|
||||
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
||||
import { EventTypeIdParams } from "@/ee/event-types/inputs/event-type-id.input";
|
||||
import { GetPublicEventTypeQueryParams } from "@/ee/event-types/inputs/get-public-event-type-query-params.input";
|
||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
||||
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
|
||||
@@ -9,6 +10,7 @@ import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-ty
|
||||
import { GetEventTypesData, GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
|
||||
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
|
||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -29,17 +31,18 @@ import {
|
||||
Delete,
|
||||
Query,
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries";
|
||||
import { getEventTypesByViewer } from "@calcom/platform-libraries";
|
||||
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
path: "event-types",
|
||||
version: "2",
|
||||
path: "/v2/event-types",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
@@ -68,7 +71,9 @@ export class EventTypesController {
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(AccessTokenGuard)
|
||||
async getEventType(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@Param() params: EventTypeIdParams,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetEventTypeOutput> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
|
||||
@@ -144,7 +149,8 @@ export class EventTypesController {
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param() params: EventTypeIdParams,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<UpdateEventTypeOutput> {
|
||||
@@ -160,7 +166,8 @@ export class EventTypesController {
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(AccessTokenGuard)
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Param() params: EventTypeIdParams,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<DeleteEventTypeOutput> {
|
||||
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries";
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsNumberString } from "class-validator";
|
||||
|
||||
export class EventTypeIdParams {
|
||||
@IsNumberString()
|
||||
eventTypeId!: number;
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output";
|
||||
import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output";
|
||||
import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GCalService } from "@/modules/apps/services/gcal.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
@@ -40,8 +41,8 @@ const CALENDAR_SCOPES = [
|
||||
|
||||
// Controller for the GCalConnect Atom
|
||||
@Controller({
|
||||
path: "/gcal",
|
||||
version: "2",
|
||||
path: "/v2/gcal",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Google Calendar")
|
||||
export class GcalController {
|
||||
@@ -81,56 +82,10 @@ export class GcalController {
|
||||
@Redirect(undefined, 301)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async save(@Query("state") state: string, @Query("code") code: string): Promise<GcalSaveRedirectOutput> {
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin } = z
|
||||
.object({ accessToken: z.string(), origin: z.string() })
|
||||
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
|
||||
|
||||
// User chose not to authorize your app or didn't authorize your app
|
||||
// redirect directly without oauth code
|
||||
if (!code) {
|
||||
return { url: origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
|
||||
if (primaryCal?.id) {
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return { url: origin };
|
||||
const url = new URL(this.config.get("api.url") + "/calendars/google/save");
|
||||
url.searchParams.append("code", code);
|
||||
url.searchParams.append("state", state);
|
||||
return { url: url.href };
|
||||
}
|
||||
|
||||
@Get("/check")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
@@ -34,14 +33,7 @@ describe("Me Endpoints", () => {
|
||||
const moduleRef = await withAccessTokenAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
AvailabilitiesModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule,
|
||||
],
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
@@ -118,13 +110,13 @@ describe("Me Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid time format", async () => {
|
||||
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 };
|
||||
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid week start", async () => {
|
||||
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" };
|
||||
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
|
||||
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -14,15 +15,15 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
|
||||
import { userSchemaResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/me",
|
||||
version: "2",
|
||||
path: "/v2/me",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@DocsTags("Me")
|
||||
export class MeController {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly schedulesRepository: SchedulesService
|
||||
private readonly schedulesRepository: SchedulesService_2024_04_15
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MeController } from "@/ee/me/me.controller";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, SchedulesModule, TokensModule],
|
||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
|
||||
controllers: [MeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { EventTypesModule } from "@/ee/event-types/event-types.module";
|
||||
import { GcalModule } from "@/ee/gcal/gcal.module";
|
||||
import { MeModule } from "@/ee/me/me.module";
|
||||
import { ProviderModule } from "@/ee/provider/provider.module";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SlotsModule } from "@/modules/slots/slots.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -13,7 +14,8 @@ import { Module } from "@nestjs/common";
|
||||
imports: [
|
||||
GcalModule,
|
||||
ProviderModule,
|
||||
SchedulesModule,
|
||||
SchedulesModule_2024_04_15,
|
||||
SchedulesModule_2024_06_11,
|
||||
MeModule,
|
||||
EventTypesModule,
|
||||
CalendarsModule,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
|
||||
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -20,8 +21,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/provider",
|
||||
version: "2",
|
||||
path: "/v2/provider",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Cal provider")
|
||||
export class CalProviderController {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { SchedulesController } from "@/ee/schedules/controllers/schedules.controller";
|
||||
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AvailabilitiesModule, UsersModule, TokensModule],
|
||||
providers: [SchedulesRepository, SchedulesService],
|
||||
controllers: [SchedulesController],
|
||||
exports: [SchedulesService, SchedulesRepository],
|
||||
})
|
||||
export class SchedulesModule {}
|
||||
+17
-23
@@ -1,12 +1,11 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
|
||||
import { CreateScheduleOutput } from "@/ee/schedules/outputs/create-schedule.output";
|
||||
import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
|
||||
import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -19,8 +18,8 @@ import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.r
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
describe("Schedules Endpoints", () => {
|
||||
describe("User Authentication", () => {
|
||||
@@ -32,7 +31,7 @@ describe("Schedules Endpoints", () => {
|
||||
const userEmail = "schedules-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
let createdSchedule: CreateScheduleOutput["data"];
|
||||
let createdSchedule: CreateScheduleOutput_2024_04_15["data"];
|
||||
const defaultAvailabilityDays = [1, 2, 3, 4, 5];
|
||||
const defaultAvailabilityStartTime = "1970-01-01T09:00:00.000Z";
|
||||
const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z";
|
||||
@@ -41,14 +40,7 @@ describe("Schedules Endpoints", () => {
|
||||
const moduleRef = await withAccessTokenAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
AvailabilitiesModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule,
|
||||
],
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
@@ -79,7 +71,7 @@ describe("Schedules Endpoints", () => {
|
||||
const scheduleTimeZone = "Europe/Rome";
|
||||
const isDefault = true;
|
||||
|
||||
const body: CreateScheduleInput = {
|
||||
const body: CreateScheduleInput_2024_04_15 = {
|
||||
name: scheduleName,
|
||||
timeZone: scheduleTimeZone,
|
||||
isDefault,
|
||||
@@ -87,10 +79,11 @@ describe("Schedules Endpoints", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/schedules")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseData: CreateScheduleOutput = response.body;
|
||||
const responseData: CreateScheduleOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data.isDefault).toEqual(isDefault);
|
||||
@@ -117,7 +110,7 @@ describe("Schedules Endpoints", () => {
|
||||
.get("/api/v2/schedules/default")
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseData: CreateScheduleOutput = response.body;
|
||||
const responseData: CreateScheduleOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data.id).toEqual(createdSchedule.id);
|
||||
@@ -137,7 +130,7 @@ describe("Schedules Endpoints", () => {
|
||||
.get(`/api/v2/schedules`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseData: GetSchedulesOutput = response.body;
|
||||
const responseData: GetSchedulesOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data?.[0].id).toEqual(createdSchedule.id);
|
||||
@@ -155,16 +148,17 @@ describe("Schedules Endpoints", () => {
|
||||
it("should update schedule name", async () => {
|
||||
const newScheduleName = "new-schedule-name";
|
||||
|
||||
const body: UpdateScheduleInput = {
|
||||
const body: UpdateScheduleInput_2024_04_15 = {
|
||||
name: newScheduleName,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput = response.body;
|
||||
const responseData: UpdateScheduleOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data.schedule.name).toEqual(newScheduleName);
|
||||
+29
-22
@@ -1,10 +1,11 @@
|
||||
import { CreateScheduleOutput } from "@/ee/schedules/outputs/create-schedule.output";
|
||||
import { DeleteScheduleOutput } from "@/ee/schedules/outputs/delete-schedule.output";
|
||||
import { GetDefaultScheduleOutput } from "@/ee/schedules/outputs/get-default-schedule.output";
|
||||
import { GetScheduleOutput } from "@/ee/schedules/outputs/get-schedule.output";
|
||||
import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
|
||||
import { DeleteScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output";
|
||||
import { GetDefaultScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output";
|
||||
import { GetScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output";
|
||||
import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { VERSION_2024_04_15_VALUE } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
@@ -26,25 +27,25 @@ import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
import { CreateScheduleInput } from "../inputs/create-schedule.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input";
|
||||
|
||||
@Controller({
|
||||
path: "schedules",
|
||||
version: "2",
|
||||
path: "/v2/schedules",
|
||||
version: VERSION_2024_04_15_VALUE,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
export class SchedulesController {
|
||||
constructor(private readonly schedulesService: SchedulesService) {}
|
||||
export class SchedulesController_2024_04_15 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: CreateScheduleInput
|
||||
): Promise<CreateScheduleOutput> {
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
@@ -56,8 +57,14 @@ export class SchedulesController {
|
||||
|
||||
@Get("/default")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({ status: 200, description: "Returns the default schedule", type: GetDefaultScheduleOutput })
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetDefaultScheduleOutput | null> {
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
type: GetDefaultScheduleOutput_2024_04_15,
|
||||
})
|
||||
async getDefaultSchedule(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
const scheduleFormatted = schedule
|
||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
||||
@@ -75,7 +82,7 @@ export class SchedulesController {
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput> {
|
||||
): Promise<GetScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
@@ -87,7 +94,7 @@ export class SchedulesController {
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput> {
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
||||
|
||||
@@ -102,9 +109,9 @@ export class SchedulesController {
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async updateSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateScheduleInput,
|
||||
@Body() bodySchedule: UpdateScheduleInput_2024_04_15,
|
||||
@Param("scheduleId") scheduleId: string
|
||||
): Promise<UpdateScheduleOutput> {
|
||||
): Promise<UpdateScheduleOutput_2024_04_15> {
|
||||
const updatedSchedule = await this.schedulesService.updateUserSchedule(
|
||||
user,
|
||||
Number(scheduleId),
|
||||
@@ -123,7 +130,7 @@ export class SchedulesController {
|
||||
async deleteSchedule(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<DeleteScheduleOutput> {
|
||||
): Promise<DeleteScheduleOutput_2024_04_15> {
|
||||
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
|
||||
|
||||
return {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform, TransformFnParams } from "class-transformer";
|
||||
import { IsArray, IsDate, IsNumber } from "class-validator";
|
||||
|
||||
export class CreateAvailabilityInput {
|
||||
export class CreateAvailabilityInput_2024_04_15 {
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ApiProperty({ example: [1, 2] })
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
export class CreateScheduleInput {
|
||||
export class CreateScheduleInput_2024_04_15 {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@@ -11,9 +11,9 @@ export class CreateScheduleInput {
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateAvailabilityInput)
|
||||
@Type(() => CreateAvailabilityInput_2024_04_15)
|
||||
@IsOptional()
|
||||
availabilities?: CreateAvailabilityInput[];
|
||||
availabilities?: CreateAvailabilityInput_2024_04_15[];
|
||||
|
||||
@IsBoolean()
|
||||
isDefault!: boolean;
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateScheduleOutput {
|
||||
export class CreateScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteScheduleOutput {
|
||||
export class DeleteScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetDefaultScheduleOutput {
|
||||
export class GetDefaultScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetScheduleOutput {
|
||||
export class GetScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetSchedulesOutput {
|
||||
export class GetSchedulesOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
|
||||
|
||||
class EventTypeModel {
|
||||
class EventTypeModel_2024_04_15 {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@@ -10,7 +10,7 @@ class EventTypeModel {
|
||||
eventName?: string | null;
|
||||
}
|
||||
|
||||
class AvailabilityModel {
|
||||
class AvailabilityModel_2024_04_15 {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@@ -46,7 +46,7 @@ class AvailabilityModel {
|
||||
date?: Date | null;
|
||||
}
|
||||
|
||||
class ScheduleModel {
|
||||
class ScheduleModel_2024_04_15 {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@@ -62,21 +62,21 @@ class ScheduleModel {
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeModel)
|
||||
@Type(() => EventTypeModel_2024_04_15)
|
||||
@IsArray()
|
||||
eventType?: EventTypeModel[];
|
||||
eventType?: EventTypeModel_2024_04_15[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AvailabilityModel)
|
||||
@Type(() => AvailabilityModel_2024_04_15)
|
||||
@IsArray()
|
||||
availability?: AvailabilityModel[];
|
||||
availability?: AvailabilityModel_2024_04_15[];
|
||||
}
|
||||
|
||||
export class UpdatedScheduleOutput {
|
||||
export class UpdatedScheduleOutput_2024_04_15 {
|
||||
@ValidateNested()
|
||||
@Type(() => ScheduleModel)
|
||||
schedule!: ScheduleModel;
|
||||
@Type(() => ScheduleModel_2024_04_15)
|
||||
schedule!: ScheduleModel_2024_04_15;
|
||||
|
||||
@IsBoolean()
|
||||
isDefault!: boolean;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { UpdatedScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: UpdatedScheduleOutput_2024_04_15,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdatedScheduleOutput_2024_04_15)
|
||||
data!: UpdatedScheduleOutput_2024_04_15;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15],
|
||||
controllers: [SchedulesController_2024_04_15],
|
||||
exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15],
|
||||
})
|
||||
export class SchedulesModule_2024_04_15 {}
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
|
||||
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesRepository {
|
||||
export class SchedulesRepository_2024_04_15 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createScheduleWithAvailabilities(
|
||||
userId: number,
|
||||
schedule: CreateScheduleInput,
|
||||
availabilities: CreateAvailabilityInput[]
|
||||
schedule: CreateScheduleInput_2024_04_15,
|
||||
availabilities: CreateAvailabilityInput_2024_04_15[]
|
||||
) {
|
||||
const createScheduleData: Prisma.ScheduleCreateInput = {
|
||||
user: {
|
||||
+28
-14
@@ -1,26 +1,25 @@
|
||||
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
|
||||
import { ScheduleOutput } from "@/ee/schedules/outputs/schedule.output";
|
||||
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
|
||||
import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service";
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Schedule } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries";
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
transformWorkingHoursForClient,
|
||||
transformAvailabilityForClient,
|
||||
transformDateOverridesForClient,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { UpdateScheduleInput } from "@calcom/platform-types";
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
export class SchedulesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository,
|
||||
private readonly availabilitiesService: AvailabilitiesService,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
@@ -34,10 +33,10 @@ export class SchedulesService {
|
||||
return this.createUserSchedule(userId, schedule);
|
||||
}
|
||||
|
||||
async createUserSchedule(userId: number, schedule: CreateScheduleInput) {
|
||||
async createUserSchedule(userId: number, schedule: CreateScheduleInput_2024_04_15) {
|
||||
const availabilities = schedule.availabilities?.length
|
||||
? schedule.availabilities
|
||||
: [this.availabilitiesService.getDefaultAvailabilityInput()];
|
||||
: [this.getDefaultAvailabilityInput()];
|
||||
|
||||
const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities(
|
||||
userId,
|
||||
@@ -76,7 +75,11 @@ export class SchedulesService {
|
||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
}
|
||||
|
||||
async updateUserSchedule(user: UserWithProfile, scheduleId: number, bodySchedule: UpdateScheduleInput) {
|
||||
async updateUserSchedule(
|
||||
user: UserWithProfile,
|
||||
scheduleId: number,
|
||||
bodySchedule: UpdateScheduleInput_2024_04_15
|
||||
) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
@@ -158,4 +161,15 @@ export class SchedulesService {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultAvailabilityInput(): CreateAvailabilityInput_2024_04_15 {
|
||||
const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0));
|
||||
const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0));
|
||||
|
||||
return {
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
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 { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_11 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
CreateScheduleOutput_2024_06_11,
|
||||
GetScheduleOutput_2024_06_11,
|
||||
GetSchedulesOutput_2024_06_11,
|
||||
ScheduleOutput_2024_06_11,
|
||||
UpdateScheduleOutput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
describe("Schedules Endpoints", () => {
|
||||
describe("User Authentication", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
const userEmail = "schedules-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const defaultAvailability: CreateScheduleInput_2024_06_11["availability"] = [
|
||||
{
|
||||
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
];
|
||||
|
||||
let createdSchedule: CreateScheduleOutput_2024_06_11["data"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withAccessTokenAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_06_11],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a default schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/schedules")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(createScheduleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
createdSchedule = response.body.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
|
||||
|
||||
const scheduleOwner = createdSchedule.ownerId
|
||||
? await userRepositoryFixture.get(createdSchedule.ownerId)
|
||||
: null;
|
||||
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
|
||||
});
|
||||
});
|
||||
|
||||
function outputScheduleMatchesExpected(
|
||||
outputSchedule: ScheduleOutput_2024_06_11 | null,
|
||||
expected: CreateScheduleInput_2024_06_11 & {
|
||||
availability: CreateScheduleInput_2024_06_11["availability"];
|
||||
} & {
|
||||
overrides: CreateScheduleInput_2024_06_11["overrides"];
|
||||
},
|
||||
expectedAvailabilityLength: number
|
||||
) {
|
||||
expect(outputSchedule).toBeTruthy();
|
||||
expect(outputSchedule?.name).toEqual(expected.name);
|
||||
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
|
||||
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
|
||||
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
|
||||
|
||||
const outputScheduleAvailability = outputSchedule?.availability[0];
|
||||
expect(outputScheduleAvailability).toBeDefined();
|
||||
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
|
||||
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
|
||||
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
|
||||
|
||||
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
|
||||
}
|
||||
|
||||
it("should get default schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/api/v2/schedules/default")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const outputSchedule = responseBody.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get schedules", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/schedules`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const outputSchedule = responseBody.data[0];
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update schedule name", async () => {
|
||||
const newScheduleName = "updated-schedule-name";
|
||||
|
||||
const body: UpdateScheduleInput_2024_06_11 = {
|
||||
name: newScheduleName,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
const responseSchedule = responseData.data;
|
||||
|
||||
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
|
||||
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
|
||||
|
||||
createdSchedule = responseSchedule;
|
||||
});
|
||||
});
|
||||
|
||||
it("should add overrides", async () => {
|
||||
const overrides = [
|
||||
{
|
||||
date: "2026-05-05",
|
||||
startTime: "10:00",
|
||||
endTime: "12:00",
|
||||
},
|
||||
];
|
||||
|
||||
const body: UpdateScheduleInput_2024_06_11 = {
|
||||
overrides,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
const responseSchedule = responseData.data;
|
||||
|
||||
const expectedSchedule = { ...createdSchedule, overrides };
|
||||
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
|
||||
|
||||
createdSchedule = responseSchedule;
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete schedule", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
try {
|
||||
await scheduleRepositoryFixture.deleteById(createdSchedule.id);
|
||||
} catch (e) {}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { VERSION_2024_06_11_VALUE } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateScheduleOutput_2024_06_11,
|
||||
CreateScheduleInput_2024_06_11,
|
||||
UpdateScheduleInput_2024_06_11,
|
||||
GetScheduleOutput_2024_06_11,
|
||||
UpdateScheduleOutput_2024_06_11,
|
||||
GetDefaultScheduleOutput_2024_06_11,
|
||||
DeleteScheduleOutput_2024_06_11,
|
||||
GetSchedulesOutput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/schedules",
|
||||
version: VERSION_2024_06_11_VALUE,
|
||||
})
|
||||
@UseGuards(AccessTokenGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
export class SchedulesController_2024_06_11 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_06_11
|
||||
): Promise<CreateScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/default")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
type: GetDefaultScheduleOutput_2024_06_11,
|
||||
})
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async updateSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
|
||||
@Param("scheduleId") scheduleId: string
|
||||
): Promise<UpdateScheduleOutput_2024_06_11> {
|
||||
const updatedSchedule = await this.schedulesService.updateUserSchedule(
|
||||
user.id,
|
||||
Number(scheduleId),
|
||||
bodySchedule
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async deleteSchedule(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<DeleteScheduleOutput_2024_06_11> {
|
||||
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SchedulesController_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/controllers/schedules.controller";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [
|
||||
SchedulesRepository_2024_06_11,
|
||||
SchedulesService_2024_06_11,
|
||||
InputSchedulesService_2024_06_11,
|
||||
OutputSchedulesService_2024_06_11,
|
||||
],
|
||||
controllers: [SchedulesController_2024_06_11],
|
||||
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11],
|
||||
})
|
||||
export class SchedulesModule_2024_06_11 {}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import type { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
type InputScheduleAvailabilityTransformed = {
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
};
|
||||
|
||||
type InputScheduleOverrideTransformed = {
|
||||
date: Date;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
};
|
||||
|
||||
type InputScheduleTransformed = Omit<CreateScheduleInput_2024_06_11, "availability" | "overrides"> & {
|
||||
availability: InputScheduleAvailabilityTransformed[];
|
||||
overrides: InputScheduleOverrideTransformed[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesRepository_2024_06_11 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createSchedule(userId: number, schedule: Omit<InputScheduleTransformed, "isDefault">) {
|
||||
const { availability, overrides } = schedule;
|
||||
|
||||
const createScheduleData: Prisma.ScheduleCreateInput = {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
name: schedule.name,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
|
||||
const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
|
||||
|
||||
if (availability && availability.length > 0) {
|
||||
availability.forEach((availability) => {
|
||||
availabilitiesAndOverrides.push({
|
||||
days: availability.days,
|
||||
startTime: availability.startTime,
|
||||
endTime: availability.endTime,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (overrides && overrides.length > 0) {
|
||||
overrides.forEach((override) => {
|
||||
availabilitiesAndOverrides.push({
|
||||
date: override.date,
|
||||
startTime: override.startTime,
|
||||
endTime: override.endTime,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (availabilitiesAndOverrides.length > 0) {
|
||||
createScheduleData.availability = {
|
||||
createMany: {
|
||||
data: availabilitiesAndOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createdSchedule = await this.dbWrite.prisma.schedule.create({
|
||||
data: {
|
||||
...createScheduleData,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return createdSchedule;
|
||||
}
|
||||
|
||||
async getScheduleById(scheduleId: number) {
|
||||
const schedule = await this.dbRead.prisma.schedule.findUnique({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
userId: number,
|
||||
scheduleId: number,
|
||||
schedule: Partial<Omit<InputScheduleTransformed, "isDefault">>
|
||||
) {
|
||||
const { availability, overrides } = schedule;
|
||||
|
||||
const updateScheduleData: Prisma.ScheduleUpdateInput = {
|
||||
name: schedule.name,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
|
||||
const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
|
||||
|
||||
const deleteConditions = [];
|
||||
if (availability) {
|
||||
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
|
||||
// but availabilities have "date" field as null, while overrides have it as not null, so delete
|
||||
// condition below results in deleting only rows from Availability table that are availabilities.
|
||||
deleteConditions.push({
|
||||
scheduleId: { equals: scheduleId },
|
||||
date: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (overrides) {
|
||||
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
|
||||
// but overrides have "date" field as not-null, while availabilities have it as null, so delete
|
||||
// condition below results in deleting only rows from Availability table that are overrides.
|
||||
deleteConditions.push({
|
||||
scheduleId: { equals: scheduleId },
|
||||
NOT: { date: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (availability && availability.length > 0) {
|
||||
availability.forEach((availability) => {
|
||||
availabilitiesAndOverrides.push({
|
||||
days: availability.days,
|
||||
startTime: availability.startTime,
|
||||
endTime: availability.endTime,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (overrides && overrides.length > 0) {
|
||||
overrides.forEach((override) => {
|
||||
availabilitiesAndOverrides.push({
|
||||
date: override.date,
|
||||
startTime: override.startTime,
|
||||
endTime: override.endTime,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (availabilitiesAndOverrides.length > 0) {
|
||||
updateScheduleData.availability = {
|
||||
deleteMany: deleteConditions,
|
||||
createMany: {
|
||||
data: availabilitiesAndOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatedSchedule = await this.dbWrite.prisma.schedule.update({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
data: {
|
||||
...updateScheduleData,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedSchedule;
|
||||
}
|
||||
|
||||
async getSchedulesByUserId(userId: number) {
|
||||
const schedules = await this.dbRead.prisma.schedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return schedules;
|
||||
}
|
||||
|
||||
async deleteScheduleById(scheduleId: number) {
|
||||
return this.dbWrite.prisma.schedule.delete({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSchedulesCount(userId: number) {
|
||||
return this.dbRead.prisma.schedule.count({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/platform-libraries-0.0.4";
|
||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class InputSchedulesService_2024_06_11 {
|
||||
transformInputCreateSchedule(inputSchedule: CreateScheduleInput_2024_06_11) {
|
||||
const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
|
||||
{
|
||||
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
];
|
||||
const defaultOverrides: ScheduleOverrideInput_2024_06_11[] = [];
|
||||
|
||||
const availability = this.transformInputScheduleAvailability(
|
||||
inputSchedule.availability || defaultAvailability
|
||||
);
|
||||
const overrides = this.transformInputOverrides(inputSchedule.overrides || defaultOverrides);
|
||||
|
||||
const internalCreateSchedule = {
|
||||
...inputSchedule,
|
||||
availability,
|
||||
overrides,
|
||||
};
|
||||
|
||||
return internalCreateSchedule;
|
||||
}
|
||||
|
||||
transformInputScheduleAvailability(inputAvailability: ScheduleAvailabilityInput_2024_06_11[]) {
|
||||
return transformApiScheduleAvailability(inputAvailability);
|
||||
}
|
||||
|
||||
transformInputOverrides(inputOverrides: ScheduleOverrideInput_2024_06_11[]) {
|
||||
return transformApiScheduleOverrides(inputOverrides);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Availability, Schedule } from "@prisma/client";
|
||||
|
||||
import { WeekDay } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OutputSchedulesService_2024_06_11 {
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) {
|
||||
if (!databaseSchedule.timeZone) {
|
||||
throw new Error("Failed to create schedule because its timezone is not set.");
|
||||
}
|
||||
|
||||
const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId(
|
||||
databaseSchedule.userId
|
||||
);
|
||||
|
||||
const createdScheduleAvailabilities = databaseSchedule.availability.filter(
|
||||
(availability) => !!availability.days.length
|
||||
);
|
||||
const createdScheduleOverrides = databaseSchedule.availability.filter((override) => !!override.date);
|
||||
|
||||
return {
|
||||
id: databaseSchedule.id,
|
||||
ownerId: databaseSchedule.userId,
|
||||
name: databaseSchedule.name,
|
||||
timeZone: databaseSchedule.timeZone,
|
||||
availability: createdScheduleAvailabilities.map((availability) => ({
|
||||
days: availability.days.map(transformNumberToDay),
|
||||
startTime: this.padHoursMinutesWithZeros(
|
||||
availability.startTime.getUTCHours() + ":" + availability.startTime.getUTCMinutes()
|
||||
),
|
||||
endTime: this.padHoursMinutesWithZeros(
|
||||
availability.endTime.getUTCHours() + ":" + availability.endTime.getUTCMinutes()
|
||||
),
|
||||
})),
|
||||
isDefault: databaseSchedule.id === ownerDefaultScheduleId,
|
||||
overrides: createdScheduleOverrides.map((override) => ({
|
||||
date:
|
||||
override.date?.getUTCFullYear() +
|
||||
"-" +
|
||||
(override.date ? override.date.getUTCMonth() + 1 : "").toString().padStart(2, "0") +
|
||||
"-" +
|
||||
override.date?.getUTCDate().toString().padStart(2, "0"),
|
||||
startTime: this.padHoursMinutesWithZeros(
|
||||
override.startTime.getUTCHours() + ":" + override.startTime.getUTCMinutes()
|
||||
),
|
||||
endTime: this.padHoursMinutesWithZeros(
|
||||
override.endTime.getUTCHours() + ":" + override.endTime.getUTCMinutes()
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
padHoursMinutesWithZeros(hhMM: string) {
|
||||
const [hours, minutes] = hhMM.split(":");
|
||||
|
||||
const formattedHours = hours.padStart(2, "0");
|
||||
const formattedMinutes = minutes.padStart(2, "0");
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
function transformNumberToDay(day: number): WeekDay {
|
||||
const weekMap: { [key: number]: WeekDay } = {
|
||||
0: "Sunday",
|
||||
1: "Monday",
|
||||
2: "Tuesday",
|
||||
3: "Wednesday",
|
||||
4: "Thursday",
|
||||
5: "Friday",
|
||||
6: "Saturday",
|
||||
};
|
||||
return weekMap[day];
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Schedule } from "@prisma/client";
|
||||
|
||||
import { CreateScheduleInput_2024_06_11, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService_2024_06_11 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||
private readonly inputSchedulesService: InputSchedulesService_2024_06_11,
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
const defaultSchedule = {
|
||||
isDefault: true,
|
||||
name: "Default schedule",
|
||||
timeZone,
|
||||
};
|
||||
|
||||
return this.createUserSchedule(userId, defaultSchedule);
|
||||
}
|
||||
|
||||
async createUserSchedule(
|
||||
userId: number,
|
||||
scheduleInput: CreateScheduleInput_2024_06_11
|
||||
): Promise<ScheduleOutput_2024_06_11> {
|
||||
const schedule = this.inputSchedulesService.transformInputCreateSchedule(scheduleInput);
|
||||
|
||||
const createdSchedule = await this.schedulesRepository.createSchedule(userId, schedule);
|
||||
|
||||
if (schedule.isDefault) {
|
||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||
}
|
||||
|
||||
return this.outputSchedulesService.getResponseSchedule(createdSchedule);
|
||||
}
|
||||
|
||||
async getUserScheduleDefault(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
const defaultSchedule = await this.schedulesRepository.getScheduleById(user.defaultScheduleId);
|
||||
|
||||
if (!defaultSchedule) return null;
|
||||
return this.outputSchedulesService.getResponseSchedule(defaultSchedule);
|
||||
}
|
||||
|
||||
async getUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsSchedule(userId, existingSchedule);
|
||||
|
||||
return this.outputSchedulesService.getResponseSchedule(existingSchedule);
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number) {
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
return Promise.all(
|
||||
schedules.map(async (schedule) => {
|
||||
return this.outputSchedulesService.getResponseSchedule(schedule);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserSchedule(userId: number, scheduleId: number, bodySchedule: UpdateScheduleInput_2024_06_11) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsSchedule(userId, existingSchedule);
|
||||
|
||||
const availability = bodySchedule.availability
|
||||
? this.inputSchedulesService.transformInputScheduleAvailability(bodySchedule.availability)
|
||||
: undefined;
|
||||
const overrides = bodySchedule.overrides
|
||||
? this.inputSchedulesService.transformInputOverrides(bodySchedule.overrides)
|
||||
: undefined;
|
||||
|
||||
if (bodySchedule.isDefault) {
|
||||
await this.usersRepository.setDefaultSchedule(userId, scheduleId);
|
||||
}
|
||||
|
||||
const updatedSchedule = await this.schedulesRepository.updateSchedule(userId, scheduleId, {
|
||||
...bodySchedule,
|
||||
availability,
|
||||
overrides,
|
||||
});
|
||||
|
||||
return this.outputSchedulesService.getResponseSchedule(updatedSchedule);
|
||||
}
|
||||
|
||||
async deleteUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsSchedule(userId, existingSchedule);
|
||||
|
||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||
if (userId !== schedule.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common";
|
||||
import { BaseExceptionFilter } from "@nestjs/core";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
@Catch()
|
||||
export class SentryFilter extends BaseExceptionFilter {
|
||||
private readonly logger = new Logger("SentryExceptionFilter");
|
||||
|
||||
handleUnknownError(exception: any, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
// capture if client has been init
|
||||
if (Boolean(Sentry.getCurrentHub().getClient())) {
|
||||
Sentry.captureException(exception);
|
||||
}
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
status: ERROR_STATUS,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
error: { code: INTERNAL_SERVER_ERROR, message: "Internal server error." },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
@Catch(TRPCError)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getEnv } from "@/env";
|
||||
import * as Sentry from "@sentry/node";
|
||||
|
||||
if (process.env.SENTRY_DSN) {
|
||||
// Ensure to call this before requiring any other modules!
|
||||
Sentry.init({
|
||||
dsn: getEnv("SENTRY_DSN"),
|
||||
// Add Performance Monitoring by setting tracesSampleRate
|
||||
// We recommend adjusting this value in production
|
||||
// todo: Evaluate? tracesSampleRate: 1.0
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionValue } from "@nestjs/common/interfaces";
|
||||
|
||||
import {
|
||||
API_VERSIONS,
|
||||
VERSION_2024_04_15,
|
||||
VERSION_2024_05_21,
|
||||
VERSION_2024_06_11,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
|
||||
export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
|
||||
export const VERSION_2024_05_21_VALUE: VersionValue = VERSION_2024_05_21 as unknown as VersionValue;
|
||||
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AvailabilitiesService],
|
||||
exports: [AvailabilitiesService],
|
||||
})
|
||||
export class AvailabilitiesModule {}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class AvailabilitiesService {
|
||||
getDefaultAvailabilityInput(): CreateAvailabilityInput {
|
||||
const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0));
|
||||
const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0));
|
||||
|
||||
return {
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
@@ -29,8 +30,8 @@ import { Stripe } from "stripe";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/billing",
|
||||
version: "2",
|
||||
path: "/v2/billing",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@ApiExcludeController(true)
|
||||
export class BillingController {
|
||||
|
||||
+6
-4
@@ -1,3 +1,4 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
BadRequestException,
|
||||
Delete,
|
||||
Query,
|
||||
NotFoundException,
|
||||
@@ -33,8 +33,8 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { Pagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "oauth-clients/:clientId/users",
|
||||
version: "2",
|
||||
path: "/v2/oauth-clients/:clientId/users",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(OAuthClientCredentialsGuard)
|
||||
@DocsTags("Managed users")
|
||||
@@ -91,6 +91,7 @@ export class OAuthClientUsersController {
|
||||
data: {
|
||||
user: this.getResponseUser(user),
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
},
|
||||
};
|
||||
@@ -155,7 +156,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
id,
|
||||
true
|
||||
@@ -166,6 +167,7 @@ export class OAuthClientUsersController {
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -18,6 +18,9 @@ class CreateManagedUserData {
|
||||
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class CreateManagedUserOutput {
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
@@ -46,8 +47,8 @@ const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-
|
||||
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
|
||||
|
||||
@Controller({
|
||||
path: "oauth-clients",
|
||||
version: "2",
|
||||
path: "/v2/oauth-clients",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
|
||||
+12
-8
@@ -1,4 +1,5 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
@@ -33,8 +34,8 @@ import { Response as ExpressResponse } from "express";
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "oauth/:clientId",
|
||||
version: "2",
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
@DocsTags("OAuth - development only")
|
||||
@@ -117,16 +118,18 @@ export class OAuthFlowController {
|
||||
throw new BadRequestException("Missing 'Bearer' Authorization header.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } =
|
||||
await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
@@ -140,7 +143,7 @@ export class OAuthFlowController {
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
): Promise<KeysResponseDto> {
|
||||
const { accessToken, refreshToken } = await this.oAuthFlowService.refreshToken(
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken(
|
||||
clientId,
|
||||
secretKey,
|
||||
body.refreshToken
|
||||
@@ -150,6 +153,7 @@ export class OAuthFlowController {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken: accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
};
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { ValidateNested, IsEnum, IsString, IsNotEmptyObject } from "class-validator";
|
||||
import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -16,6 +16,9 @@ class KeysDto {
|
||||
})
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class KeysResponseDto {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventTypesModule } from "@/ee/event-types/event-types.module";
|
||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
@@ -32,7 +32,7 @@ import { Global, Module } from "@nestjs/common";
|
||||
OrganizationsModule,
|
||||
StripeModule,
|
||||
BillingModule,
|
||||
SchedulesModule,
|
||||
SchedulesModule_2024_04_15,
|
||||
],
|
||||
providers: [
|
||||
OAuthClientRepository,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
@@ -15,7 +15,7 @@ export class OAuthClientUsersService {
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly eventTypesService: EventTypesService,
|
||||
private readonly schedulesService: SchedulesService
|
||||
private readonly schedulesService: SchedulesService_2024_04_15
|
||||
) {}
|
||||
|
||||
async createOauthClientUser(
|
||||
@@ -62,7 +62,7 @@ export class OAuthClientUsersService {
|
||||
await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
user.id
|
||||
);
|
||||
@@ -78,6 +78,7 @@ export class OAuthClientUsersService {
|
||||
user,
|
||||
tokens: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export class OAuthFlowService {
|
||||
tokenId: string,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens(
|
||||
tokenId,
|
||||
clientId,
|
||||
@@ -120,7 +120,7 @@ export class OAuthFlowService {
|
||||
throw new BadRequestException("Invalid Authorization Token.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
clientId,
|
||||
authorizationToken.owner.id
|
||||
);
|
||||
@@ -129,6 +129,7 @@ export class OAuthFlowService {
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
@@ -158,6 +159,7 @@ export class OAuthFlowService {
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
||||
import { SlotsService } from "@/modules/slots/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||
@@ -5,14 +6,14 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.2";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.2";
|
||||
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "slots",
|
||||
version: "2",
|
||||
path: "/v2/slots",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Slots")
|
||||
export class SlotsController {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
|
||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.2";
|
||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "timezones",
|
||||
version: "2",
|
||||
path: "/v2/timezones",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Timezones")
|
||||
export class TimezonesController {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries";
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
@Injectable()
|
||||
export class TimezonesService {
|
||||
|
||||
@@ -89,6 +89,7 @@ export class TokensRepository {
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +173,14 @@ export class UsersRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserScheduleDefaultId(userId: number) {
|
||||
const user = await this.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return user?.defaultScheduleId;
|
||||
}
|
||||
}
|
||||
|
||||
function capitalizeTimezone(timezone: string) {
|
||||
|
||||
@@ -634,7 +634,7 @@
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -808,14 +808,14 @@
|
||||
},
|
||||
"/v2/schedules": {
|
||||
"post": {
|
||||
"operationId": "SchedulesController_createSchedule",
|
||||
"operationId": "SchedulesController_2024_06_11_createSchedule",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateScheduleInput"
|
||||
"$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -826,7 +826,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateScheduleOutput"
|
||||
"$ref": "#/components/schemas/CreateScheduleOutput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -837,7 +837,7 @@
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "SchedulesController_getSchedules",
|
||||
"operationId": "SchedulesController_2024_06_11_getSchedules",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -845,7 +845,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetSchedulesOutput"
|
||||
"$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,7 +858,7 @@
|
||||
},
|
||||
"/v2/schedules/default": {
|
||||
"get": {
|
||||
"operationId": "SchedulesController_getDefaultSchedule",
|
||||
"operationId": "SchedulesController_2024_06_11_getDefaultSchedule",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -866,7 +866,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetDefaultScheduleOutput"
|
||||
"$ref": "#/components/schemas/GetDefaultScheduleOutput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -879,7 +879,7 @@
|
||||
},
|
||||
"/v2/schedules/{scheduleId}": {
|
||||
"get": {
|
||||
"operationId": "SchedulesController_getSchedule",
|
||||
"operationId": "SchedulesController_2024_06_11_getSchedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "scheduleId",
|
||||
@@ -896,7 +896,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetScheduleOutput"
|
||||
"$ref": "#/components/schemas/GetScheduleOutput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -907,7 +907,7 @@
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "SchedulesController_updateSchedule",
|
||||
"operationId": "SchedulesController_2024_06_11_updateSchedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "scheduleId",
|
||||
@@ -923,7 +923,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateScheduleInput"
|
||||
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,7 +934,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateScheduleOutput"
|
||||
"$ref": "#/components/schemas/UpdateScheduleOutput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -945,7 +945,7 @@
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "SchedulesController_deleteSchedule",
|
||||
"operationId": "SchedulesController_2024_06_11_deleteSchedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "scheduleId",
|
||||
@@ -962,7 +962,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteScheduleOutput"
|
||||
"$ref": "#/components/schemas/DeleteScheduleOutput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1214,6 +1214,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/connect": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_redirect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/save": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_save",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/check": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_check",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/bookings": {
|
||||
"get": {
|
||||
"operationId": "BookingsController_getBookings",
|
||||
@@ -1726,12 +1833,16 @@
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"accessTokenExpiresAt": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"accessToken",
|
||||
"refreshToken"
|
||||
"refreshToken",
|
||||
"accessTokenExpiresAt"
|
||||
]
|
||||
},
|
||||
"CreateManagedUserOutput": {
|
||||
@@ -2895,7 +3006,7 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreateAvailabilityInput": {
|
||||
"CreateAvailabilityInput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
@@ -2923,7 +3034,7 @@
|
||||
"endTime"
|
||||
]
|
||||
},
|
||||
"CreateScheduleInput": {
|
||||
"CreateScheduleInput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -2935,7 +3046,7 @@
|
||||
"availabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CreateAvailabilityInput"
|
||||
"$ref": "#/components/schemas/CreateAvailabilityInput_2024_04_15"
|
||||
}
|
||||
},
|
||||
"isDefault": {
|
||||
@@ -3107,7 +3218,7 @@
|
||||
"readOnly"
|
||||
]
|
||||
},
|
||||
"CreateScheduleOutput": {
|
||||
"CreateScheduleOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -3127,7 +3238,7 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetDefaultScheduleOutput": {
|
||||
"GetDefaultScheduleOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -3152,7 +3263,7 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetScheduleOutput": {
|
||||
"GetScheduleOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -3172,7 +3283,7 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetSchedulesOutput": {
|
||||
"GetSchedulesOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -3192,7 +3303,7 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdateScheduleInput": {
|
||||
"UpdateScheduleInput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeZone": {
|
||||
@@ -3252,7 +3363,7 @@
|
||||
"schedule"
|
||||
]
|
||||
},
|
||||
"EventTypeModel": {
|
||||
"EventTypeModel_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -3267,7 +3378,50 @@
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"ScheduleModel": {
|
||||
"AvailabilityModel_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"scheduleId": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"startTime": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"endTime": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"days"
|
||||
]
|
||||
},
|
||||
"ScheduleModel_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -3286,13 +3440,13 @@
|
||||
"eventType": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EventTypeModel"
|
||||
"$ref": "#/components/schemas/EventTypeModel_2024_04_15"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AvailabilityModel"
|
||||
"$ref": "#/components/schemas/AvailabilityModel_2024_04_15"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3302,11 +3456,11 @@
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"UpdatedScheduleOutput": {
|
||||
"UpdatedScheduleOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schedule": {
|
||||
"$ref": "#/components/schemas/ScheduleModel"
|
||||
"$ref": "#/components/schemas/ScheduleModel_2024_04_15"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean"
|
||||
@@ -3328,7 +3482,7 @@
|
||||
"isDefault"
|
||||
]
|
||||
},
|
||||
"UpdateScheduleOutput": {
|
||||
"UpdateScheduleOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -3340,7 +3494,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/UpdatedScheduleOutput"
|
||||
"$ref": "#/components/schemas/UpdatedScheduleOutput_2024_04_15"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -3348,7 +3502,7 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"DeleteScheduleOutput": {
|
||||
"DeleteScheduleOutput_2024_04_15": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -3454,6 +3608,349 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ScheduleAvailabilityInput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"example": [
|
||||
"Monday",
|
||||
"Tuesday"
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"startTime": {
|
||||
"type": "string",
|
||||
"example": "09:00"
|
||||
},
|
||||
"endTime": {
|
||||
"type": "string",
|
||||
"example": "10:00"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"days",
|
||||
"startTime",
|
||||
"endTime"
|
||||
]
|
||||
},
|
||||
"ScheduleOverrideInput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"example": "2024-05-20"
|
||||
},
|
||||
"startTime": {
|
||||
"type": "string",
|
||||
"example": "12:00"
|
||||
},
|
||||
"endTime": {
|
||||
"type": "string",
|
||||
"example": "13:00"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"startTime",
|
||||
"endTime"
|
||||
]
|
||||
},
|
||||
"CreateScheduleInput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "One-on-one coaching"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
"example": "Europe/Rome"
|
||||
},
|
||||
"availability": {
|
||||
"example": [
|
||||
{
|
||||
"days": [
|
||||
"Monday",
|
||||
"Tuesday"
|
||||
],
|
||||
"startTime": "09:00",
|
||||
"endTime": "10:00"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
|
||||
}
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"overrides": {
|
||||
"example": [
|
||||
{
|
||||
"date": "2024-05-20",
|
||||
"startTime": "12:00",
|
||||
"endTime": "14:00"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"timeZone",
|
||||
"isDefault"
|
||||
]
|
||||
},
|
||||
"ScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"example": 254
|
||||
},
|
||||
"ownerId": {
|
||||
"type": "number",
|
||||
"example": 478
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "One-on-one coaching"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
"example": "Europe/Rome"
|
||||
},
|
||||
"availability": {
|
||||
"example": [
|
||||
{
|
||||
"days": [
|
||||
"Monday",
|
||||
"Tuesday"
|
||||
],
|
||||
"startTime": "09:00",
|
||||
"endTime": "10:00"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
|
||||
}
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"overrides": {
|
||||
"example": [
|
||||
{
|
||||
"date": "2024-05-20",
|
||||
"startTime": "12:00",
|
||||
"endTime": "13:00"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"ownerId",
|
||||
"name",
|
||||
"timeZone",
|
||||
"availability",
|
||||
"isDefault",
|
||||
"overrides"
|
||||
]
|
||||
},
|
||||
"CreateScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetDefaultScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
|
||||
}
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetSchedulesOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdateScheduleInput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "One-on-one coaching"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
"example": "Europe/Rome"
|
||||
},
|
||||
"availability": {
|
||||
"example": [
|
||||
{
|
||||
"days": [
|
||||
"Monday",
|
||||
"Tuesday"
|
||||
],
|
||||
"startTime": "09:00",
|
||||
"endTime": "10:00"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
|
||||
}
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"overrides": {
|
||||
"example": [
|
||||
{
|
||||
"date": "2024-05-20",
|
||||
"startTime": "12:00",
|
||||
"endTime": "14:00"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
|
||||
},
|
||||
"error": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"DeleteScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"MeOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -53,9 +53,9 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type PersonalAccountProps = Pick<User, "id" | "avatar" | "name"> & { alreadyInstalled: boolean };
|
||||
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
|
||||
|
||||
export type TeamsProp = (Pick<Team, "id" | "name" | "logo"> & {
|
||||
export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
|
||||
alreadyInstalled: boolean;
|
||||
})[];
|
||||
|
||||
@@ -74,7 +74,7 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
|
||||
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
|
||||
<AccountSelector
|
||||
testId="install-app-button-personal"
|
||||
avatar={personalAccount.avatar ?? ""}
|
||||
avatar={personalAccount.avatarUrl ?? ""}
|
||||
name={personalAccount.name ?? ""}
|
||||
alreadyInstalled={personalAccount.alreadyInstalled}
|
||||
onClick={() => onSelect()}
|
||||
@@ -85,7 +85,7 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
|
||||
key={team.id}
|
||||
testId={`install-app-button-team${team.id}`}
|
||||
alreadyInstalled={team.alreadyInstalled}
|
||||
avatar={team.logo ?? ""}
|
||||
avatar={team.logoUrl ?? ""}
|
||||
name={team.name}
|
||||
onClick={() => onSelect(team.id)}
|
||||
loading={loading}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
|
||||
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -35,6 +37,14 @@ import {
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
@@ -87,7 +97,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
@@ -220,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
if (isPast && isPending && !isConfirmed) {
|
||||
if (isBookingInPast && isPending && !isConfirmed) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||
}
|
||||
|
||||
@@ -278,9 +288,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
|
||||
const showCheckRecordingButton =
|
||||
isPast &&
|
||||
isBookingInPast &&
|
||||
isConfirmed &&
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
@@ -298,7 +308,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
];
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -436,7 +453,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={booking.attendees}
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
@@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
@@ -654,13 +673,215 @@ const FirstAttendee = ({
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||
type NoShowProps = {
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
};
|
||||
|
||||
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [noShow, setNoShow] = useState(noShowAttendee);
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(
|
||||
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
|
||||
x: name || email,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function toggleNoShow({
|
||||
attendee,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendee: { email: string; noShow: boolean };
|
||||
bookingUid: string;
|
||||
}) {
|
||||
noShowMutation.mutate({ bookingUid, attendees: [attendee] });
|
||||
setNoShow(!noShow);
|
||||
}
|
||||
|
||||
return (
|
||||
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
|
||||
{name || email}
|
||||
</a>
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="guest"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<s>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
</s>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${email}`}
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<a href={`mailto:${email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
copyToClipboard(email);
|
||||
setOpenDropdown(false);
|
||||
showToast(t("email_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
{noShow ? (
|
||||
<DropdownItem
|
||||
data-testid="unmark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye">
|
||||
{t("unmark_as_no_show")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<DropdownItem
|
||||
data-testid="mark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye-off">
|
||||
{t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type GroupedAttendeeProps = {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
const { bookingUid } = groupedAttendeeProps;
|
||||
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
const { t } = useLocale();
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("no_show_updated"), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit } = useForm<{
|
||||
attendees: AttendeeProps[];
|
||||
}>({
|
||||
defaultValues: {
|
||||
attendees,
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: "attendees",
|
||||
});
|
||||
|
||||
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
|
||||
const filteredData = data.attendees.slice(1);
|
||||
noShowMutation.mutate({ bookingUid, attendees: filteredData });
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
||||
{t("plus_more", { count: attendees.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">
|
||||
{t("mark_as_no_show_title")}
|
||||
</DropdownMenuLabel>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.slice(1).map((field, index) => (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="pr-8 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<span className={value ? "line-through" : ""}>{field.email}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<div className=" flex justify-end p-2">
|
||||
<Button
|
||||
data-testid="update-no-show"
|
||||
color="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onSubmit)();
|
||||
}}>
|
||||
{t("mark_as_no_show_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -668,17 +889,23 @@ const DisplayAttendees = ({
|
||||
attendees,
|
||||
user,
|
||||
currentEmail,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
user: UserProps | null;
|
||||
currentEmail?: string | null;
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
attendees.sort((a, b) => a.id - b.id);
|
||||
|
||||
return (
|
||||
<div className="text-emphasis text-sm">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} />
|
||||
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="text-emphasis inline-block text-sm"> {t("and")} </div>
|
||||
@@ -686,13 +913,13 @@ const DisplayAttendees = ({
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
<p key={attendee.email}>
|
||||
<Attendee {...attendee} />
|
||||
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</p>
|
||||
))}>
|
||||
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
|
||||
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Attendee {...attendees[1]} />
|
||||
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogContent, Button, DialogFooter } from "@calcom/ui";
|
||||
|
||||
interface AssignmentWarningDialogProps {
|
||||
isOpenAssignmentWarnDialog: boolean;
|
||||
setIsOpenAssignmentWarnDialog: Dispatch<SetStateAction<boolean>>;
|
||||
pendingRoute: string;
|
||||
leaveWithoutAssigningHosts: MutableRefObject<boolean>;
|
||||
id: number;
|
||||
}
|
||||
const AssignmentWarningDialog = (props: AssignmentWarningDialogProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
isOpenAssignmentWarnDialog,
|
||||
setIsOpenAssignmentWarnDialog,
|
||||
pendingRoute,
|
||||
leaveWithoutAssigningHosts,
|
||||
id,
|
||||
} = props;
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Dialog open={isOpenAssignmentWarnDialog} onOpenChange={setIsOpenAssignmentWarnDialog}>
|
||||
<DialogContent title={t("leave_without_assigning_anyone")} Icon="circle-alert" type="confirmation">
|
||||
<div className="text-subtle text-sm">
|
||||
<p className="mt-3">{t("leave_without_adding_attendees")}</p>
|
||||
<p className="mt-4">{t("no_availability_shown_to_bookers")}</p>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpenAssignmentWarnDialog(false);
|
||||
router.replace(`/event-types/${id}?tabName=team`);
|
||||
}}
|
||||
color="minimal">
|
||||
{t("go_back_and_assign")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpenAssignmentWarnDialog(false);
|
||||
leaveWithoutAssigningHosts.current = true;
|
||||
router.replace(pendingRoute);
|
||||
}}>
|
||||
{t("leave_without_assigning")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default AssignmentWarningDialog;
|
||||
@@ -82,6 +82,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
);
|
||||
const placeholderHashedLink = `${WEBSITE_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
const multiLocation = (formMethods.getValues("locations") || []).length > 1;
|
||||
const noShowFeeEnabled =
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.enabled === true &&
|
||||
formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD";
|
||||
@@ -434,7 +435,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
{...seatsLocked}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
disabled={noShowFeeEnabled}
|
||||
disabled={noShowFeeEnabled || multiLocation}
|
||||
tooltip={
|
||||
multiLocation
|
||||
? t("multilocation_doesnt_support_seats")
|
||||
: noShowFeeEnabled
|
||||
? t("no_show_fee_doesnt_support_seats")
|
||||
: undefined
|
||||
}
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
|
||||
@@ -158,6 +158,7 @@ export const EventSetupTab = (
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||
const validLocations = formMethods.getValues("locations").filter((location) => {
|
||||
const eventLocation = getEventLocationType(location.type);
|
||||
if (!eventLocation) {
|
||||
@@ -444,6 +445,8 @@ export const EventSetupTab = (
|
||||
data-testid="add-location"
|
||||
StartIcon="plus"
|
||||
color="minimal"
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
|
||||
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||
{t("add_location")}
|
||||
</Button>
|
||||
|
||||
@@ -33,6 +33,7 @@ export const mapMemberToChildrenOption = (
|
||||
membership: member.membership,
|
||||
eventTypeSlugs: member.eventTypes ?? [],
|
||||
avatar: member.avatar,
|
||||
profile: member.profile,
|
||||
},
|
||||
value: `${member.id ?? ""}`,
|
||||
label: `${member.name || member.email || ""}${!member.username ? ` (${pendingString})` : ""}`,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
export default function NoPlatformPlan() {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="credit-card"
|
||||
headline="Subscription needed"
|
||||
description="You are not subscribed to a Platform plan."
|
||||
buttonRaw={
|
||||
<div className="flex gap-2">
|
||||
<Button href="https://cal.com/platform/pricing">Go to Pricing</Button>
|
||||
<Button color="secondary" href="https://cal.com/pricing">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
// reduce sample rate to 10% on production
|
||||
tracesSampleRate: process.env.NODE_ENV !== "production" ? 1.0 : 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/feature-auth/lib/getServerSession";
|
||||
import { AUTH_OPTIONS } from "@calcom/feature-auth/lib/next-auth-options";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const session = await getServerSession({
|
||||
req: context.req,
|
||||
res: context.res,
|
||||
authOptions: AUTH_OPTIONS,
|
||||
});
|
||||
// Disable this check if we ever make this self serve.
|
||||
if (session?.user.role !== "ADMIN") {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
};
|
||||
@@ -955,6 +955,7 @@ const EventTypesPage: React.FC & {
|
||||
const orgBranding = useOrgBranding();
|
||||
const routerQuery = useRouterQuery();
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
const router = useRouter();
|
||||
|
||||
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
|
||||
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
|
||||
@@ -967,6 +968,13 @@ const EventTypesPage: React.FC & {
|
||||
if (searchParams?.get("openIntercom") === "true") {
|
||||
open();
|
||||
}
|
||||
/**
|
||||
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding.
|
||||
* Adding this redirection logic here as well to ensure the user is redirected to the correct redirectUrl.
|
||||
*/
|
||||
const redirectUrl = localStorage.getItem("onBoardingRedirect");
|
||||
localStorage.removeItem("onBoardingRedirect");
|
||||
redirectUrl && router.push(redirectUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -113,6 +115,8 @@ const EventAITab = dynamic(() => import("@components/eventtype/EventAITab").then
|
||||
|
||||
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
|
||||
|
||||
const AssignmentWarningDialog = dynamic(() => import("@components/eventtype/AssignmentWarningDialog"));
|
||||
|
||||
export type Host = { isFixed: boolean; userId: number; priority: number };
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
@@ -154,6 +158,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
});
|
||||
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
|
||||
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
|
||||
const [pendingRoute, setPendingRoute] = useState("");
|
||||
const leaveWithoutAssigningHosts = useRef(false);
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -196,6 +203,32 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url: string) => {
|
||||
const paths = url.split("/");
|
||||
if (
|
||||
!!team &&
|
||||
eventType.hosts.length === 0 &&
|
||||
!leaveWithoutAssigningHosts.current &&
|
||||
(url === "/event-types" || paths[1] !== "event-types")
|
||||
) {
|
||||
setIsOpenAssignmentWarnDialog(true);
|
||||
setPendingRoute(url);
|
||||
router.events.emit(
|
||||
"routeChangeError",
|
||||
new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
||||
);
|
||||
throw "Aborted";
|
||||
}
|
||||
};
|
||||
router.events.on("routeChangeStart", handleRouteChange);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", handleRouteChange);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
|
||||
startDate: new Date(eventType.periodStartDate || Date.now()),
|
||||
endDate: new Date(eventType.periodEndDate || Date.now()),
|
||||
@@ -774,7 +807,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
<div ref={animationParentRef}>{tabMap[tabName]}</div>
|
||||
</Form>
|
||||
</EventTypeSingleLayout>
|
||||
|
||||
{slugExistsChildrenDialogOpen.length ? (
|
||||
<ManagedEventTypeDialog
|
||||
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
|
||||
@@ -791,10 +823,16 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<AssignmentWarningDialog
|
||||
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
|
||||
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
|
||||
pendingRoute={pendingRoute}
|
||||
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
|
||||
id={eventType.id}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypePageWrapper: React.FC<PageProps> & {
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
getLayout?: AppProps["Component"]["getLayout"];
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HeadSeo, Icon, UnpublishedEntity, UserAvatar } from "@calcom/ui";
|
||||
@@ -26,7 +25,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
|
||||
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
@@ -52,7 +50,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
}, [telemetry, router.asPath]); */
|
||||
if (entity.considerUnpublished) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<div className="flex h-full min-h-[calc(100dvh)] items-center justify-center">
|
||||
<UnpublishedEntity {...entity} />
|
||||
</div>
|
||||
);
|
||||
@@ -81,7 +79,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md" : "",
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
<div className="mb-8 text-center">
|
||||
@@ -126,7 +124,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
<div
|
||||
key={type.id}
|
||||
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
|
||||
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
|
||||
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0">
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
|
||||
+10
-8
@@ -158,10 +158,12 @@ const matcherConfigUserTypeEmbedRoute = {
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
||||
serverComponentsExternalPackages: ["next-i18next"],
|
||||
optimizePackageImports: ["@calcom/ui"],
|
||||
instrumentationHook: true,
|
||||
},
|
||||
i18n: {
|
||||
...i18n,
|
||||
@@ -590,14 +592,14 @@ const nextConfig = {
|
||||
};
|
||||
|
||||
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
nextConfig["sentry"] = {
|
||||
autoInstrumentServerFunctions: true,
|
||||
hideSourceMaps: true,
|
||||
// disable source map generation for the server code
|
||||
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
|
||||
};
|
||||
|
||||
plugins.push(withSentryConfig);
|
||||
plugins.push((nextConfig) =>
|
||||
withSentryConfig(nextConfig, {
|
||||
autoInstrumentServerFunctions: true,
|
||||
hideSourceMaps: true,
|
||||
// disable source map generation for the server code
|
||||
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.1.6",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -60,7 +60,7 @@
|
||||
"@radix-ui/react-switch": "^1.0.0",
|
||||
"@radix-ui/react-toggle-group": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@sentry/nextjs": "^7.73.0",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
|
||||
@@ -3,10 +3,16 @@ import { createHmac } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
|
||||
import {
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
submitBatchProcessorTranscriptionJob,
|
||||
} from "@calcom/core/videoClient";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
|
||||
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
|
||||
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
@@ -17,26 +23,40 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
|
||||
|
||||
const schema = z
|
||||
const commonSchema = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
payload: z.object({
|
||||
recording_id: z.string(),
|
||||
end_ts: z.number().optional(),
|
||||
room_name: z.string(),
|
||||
start_ts: z.number().optional(),
|
||||
status: z.string(),
|
||||
|
||||
max_participants: z.number().optional(),
|
||||
duration: z.number().optional(),
|
||||
s3_key: z.string().optional(),
|
||||
}),
|
||||
event_ts: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const meetingEndedSchema = commonSchema.extend({
|
||||
payload: z
|
||||
.object({
|
||||
meeting_id: z.string(),
|
||||
end_ts: z.number().optional(),
|
||||
room: z.string(),
|
||||
start_ts: z.number().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
});
|
||||
|
||||
const recordingReadySchema = commonSchema.extend({
|
||||
payload: z.object({
|
||||
recording_id: z.string(),
|
||||
end_ts: z.number().optional(),
|
||||
room_name: z.string(),
|
||||
start_ts: z.number().optional(),
|
||||
status: z.string(),
|
||||
|
||||
max_participants: z.number().optional(),
|
||||
duration: z.number().optional(),
|
||||
s3_key: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const downloadLinkSchema = z.object({
|
||||
download_link: z.string(),
|
||||
});
|
||||
@@ -60,11 +80,16 @@ const triggerWebhook = async ({
|
||||
// Send Webhook call if hooked to BOOKING.RECORDING_READY
|
||||
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
|
||||
|
||||
const organizerUserId = triggerForUser ? booking.userId : null;
|
||||
|
||||
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
|
||||
|
||||
const subscriberOptions = {
|
||||
userId: triggerForUser ? booking.userId : null,
|
||||
userId: organizerUserId,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
triggerEvent: eventTrigger,
|
||||
teamId: booking.teamId,
|
||||
orgId,
|
||||
};
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
|
||||
@@ -89,6 +114,102 @@ const triggerWebhook = async ({
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
const getBooking = async (bookingId: number) => {
|
||||
const booking = await prisma.booking.findUniqueOrThrow({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
uid: true,
|
||||
location: true,
|
||||
isRecorded: true,
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
teamId: true,
|
||||
parentId: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
log.error(
|
||||
"Couldn't find Booking Id:",
|
||||
safeStringify({
|
||||
bookingId,
|
||||
})
|
||||
);
|
||||
}
|
||||
return booking;
|
||||
};
|
||||
|
||||
const getBookingReference = async (roomName: string) => {
|
||||
const bookingReference = await prisma.bookingReference.findFirst({
|
||||
where: { type: "daily_video", uid: roomName, meetingId: roomName },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
|
||||
if (!bookingReference) {
|
||||
log.error(
|
||||
"bookingReference not found error:",
|
||||
safeStringify({
|
||||
bookingReference,
|
||||
roomName,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return bookingReference;
|
||||
};
|
||||
|
||||
const getCalendarEvent = async (booking: Awaited<ReturnType<typeof getBooking>>) => {
|
||||
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
|
||||
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||
name: booking.user?.name || "Nameless",
|
||||
timeZone: booking.user?.timeZone || "Europe/London",
|
||||
language: { translate: t, locale: booking?.user?.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
};
|
||||
|
||||
return Promise.resolve(evt);
|
||||
};
|
||||
|
||||
const testRequestSchema = z.object({
|
||||
test: z.enum(["test"]),
|
||||
});
|
||||
@@ -116,157 +237,121 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(403).json({ message: "Signature does not match" });
|
||||
}
|
||||
|
||||
const response = schema.safeParse(req.body);
|
||||
|
||||
log.debug(
|
||||
"Daily video recording webhook Request Body:",
|
||||
"Daily video webhook Request Body:",
|
||||
safeStringify({
|
||||
response,
|
||||
body: req.body,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.success || response.data.type !== "recording.ready-to-download") {
|
||||
return res.status(400).send({
|
||||
message: "Invalid Payload",
|
||||
});
|
||||
}
|
||||
|
||||
const { room_name, recording_id, status } = response.data.payload;
|
||||
|
||||
if (status !== "finished") {
|
||||
return res.status(400).send({
|
||||
message: "Recording not finished",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const bookingReference = await prisma.bookingReference.findFirst({
|
||||
where: { type: "daily_video", uid: room_name, meetingId: room_name },
|
||||
select: { bookingId: true },
|
||||
});
|
||||
if (req.body?.type === "recording.ready-to-download") {
|
||||
const recordingReadyResponse = recordingReadySchema.safeParse(req.body);
|
||||
|
||||
if (!bookingReference || !bookingReference.bookingId) {
|
||||
log.error(
|
||||
"bookingReference not found error:",
|
||||
safeStringify({
|
||||
bookingReference,
|
||||
room_name,
|
||||
recording_id,
|
||||
})
|
||||
);
|
||||
return res.status(200).send({ message: "Booking reference not found" });
|
||||
}
|
||||
if (!recordingReadyResponse.success) {
|
||||
return res.status(400).send({
|
||||
message: "Invalid Payload",
|
||||
});
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({
|
||||
where: {
|
||||
id: bookingReference.bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
uid: true,
|
||||
location: true,
|
||||
isRecorded: true,
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
teamId: true,
|
||||
parentId: true,
|
||||
},
|
||||
const { room_name, recording_id, status } = recordingReadyResponse.data.payload;
|
||||
|
||||
if (status !== "finished") {
|
||||
return res.status(400).send({
|
||||
message: "Recording not finished",
|
||||
});
|
||||
}
|
||||
const bookingReference = await getBookingReference(room_name);
|
||||
|
||||
if (!bookingReference || !bookingReference.bookingId) {
|
||||
return res.status(200).send({ message: "Booking reference not found" });
|
||||
}
|
||||
|
||||
const booking = await getBooking(bookingReference.bookingId);
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).send({
|
||||
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
|
||||
});
|
||||
}
|
||||
|
||||
const evt = await getCalendarEvent(booking);
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: booking.uid,
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
data: {
|
||||
isRecorded: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
log.error(
|
||||
"Booking:",
|
||||
safeStringify({
|
||||
booking,
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(404).send({
|
||||
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
|
||||
});
|
||||
}
|
||||
|
||||
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
|
||||
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
||||
const downloadLink = downloadLinkResponse.download_link;
|
||||
|
||||
const teamId = await getTeamIdFromEventType({
|
||||
eventType: {
|
||||
team: { id: booking?.eventType?.teamId ?? null },
|
||||
parentId: booking?.eventType?.parentId ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
await triggerWebhook({
|
||||
evt,
|
||||
downloadLink,
|
||||
booking: {
|
||||
userId: booking?.user?.id,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
eventTypeParentId: booking.eventType?.parentId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: booking.uid,
|
||||
},
|
||||
data: {
|
||||
isRecorded: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Submit Transcription Batch Processor Job
|
||||
await submitBatchProcessorTranscriptionJob(recording_id);
|
||||
} catch (err) {
|
||||
log.error("Failed to Submit Transcription Batch Processor Job:", safeStringify(err));
|
||||
}
|
||||
|
||||
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
|
||||
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
||||
const downloadLink = downloadLinkResponse.download_link;
|
||||
// send emails to all attendees only when user has team plan
|
||||
await sendDailyVideoRecordingEmails(evt, downloadLink);
|
||||
return res.status(200).json({ message: "Success" });
|
||||
} else if (req.body.type === "meeting.ended") {
|
||||
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
|
||||
if (!meetingEndedResponse.success) {
|
||||
return res.status(400).send({
|
||||
message: "Invalid Payload",
|
||||
});
|
||||
}
|
||||
const { room } = meetingEndedResponse.data.payload;
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||
name: booking.user?.name || "Nameless",
|
||||
timeZone: booking.user?.timeZone || "Europe/London",
|
||||
language: { translate: t, locale: booking?.user?.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
};
|
||||
const bookingReference = await getBookingReference(room);
|
||||
if (!bookingReference || !bookingReference.bookingId) {
|
||||
return res.status(200).send({ message: "Booking reference not found" });
|
||||
}
|
||||
|
||||
const teamId = await getTeamIdFromEventType({
|
||||
eventType: {
|
||||
team: { id: booking?.eventType?.teamId ?? null },
|
||||
parentId: booking?.eventType?.parentId ?? null,
|
||||
},
|
||||
});
|
||||
const booking = await getBooking(bookingReference.bookingId);
|
||||
|
||||
await triggerWebhook({
|
||||
evt,
|
||||
downloadLink,
|
||||
booking: {
|
||||
userId: booking?.user?.id,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
eventTypeParentId: booking.eventType?.parentId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
return res.status(404).send({
|
||||
message: `Booking of room_name ${room} does not exist or does not contain daily video as location`,
|
||||
});
|
||||
}
|
||||
|
||||
// send emails to all attendees only when user has team plan
|
||||
await sendDailyVideoRecordingEmails(evt, downloadLink);
|
||||
return res.status(200).json({ message: "Success" });
|
||||
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
|
||||
|
||||
if (!transcripts || !transcripts.length)
|
||||
return res.status(200).json({ message: `No Transcripts found for room name ${room}` });
|
||||
|
||||
const evt = await getCalendarEvent(booking);
|
||||
await sendDailyVideoTranscriptEmails(evt, transcripts);
|
||||
|
||||
return res.status(200).json({ message: "Success" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error in /recorded-daily-video", err);
|
||||
log.error("Error in /recorded-daily-video", err);
|
||||
return res.status(500).json({ message: "something went wrong" });
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user