Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa09fd66ff | ||
|
|
b1cbeebe48 | ||
|
|
93bad282d7 | ||
|
|
47d941676e | ||
|
|
54c4b0b65e | ||
|
|
0f8356f417 | ||
|
|
cecfcc7c6f | ||
|
|
0b3f9d06a9 | ||
|
|
71e7300830 | ||
|
|
bccffb328b | ||
|
|
80fa172f79 | ||
|
|
50173658f8 | ||
|
|
dddbd7dad4 | ||
|
|
ae2d85d81b | ||
|
|
80c407a4b7 | ||
|
|
6e9ec113fe | ||
|
|
3ee6a7ecde | ||
|
|
8ad62d183c | ||
|
|
f047799299 | ||
|
|
3a9e353443 | ||
|
|
b73a3aeb0c | ||
|
|
b6ff64ed74 | ||
|
|
1b281fe269 | ||
|
|
024913592b | ||
|
|
78a34038d2 | ||
|
|
5f770a9789 | ||
|
|
24a43877ce | ||
|
|
4a96be4175 | ||
|
|
594bb606b8 | ||
|
|
1c088f65c5 | ||
|
|
7d7a429780 | ||
|
|
d3228441aa | ||
|
|
21d6bf1fca | ||
|
|
a4134aa5a3 | ||
|
|
61397e5184 | ||
|
|
6143cd60e5 | ||
|
|
ac821f6b13 | ||
|
|
2bce38fd2a | ||
|
|
390ee79134 | ||
|
|
cfc9f0bfc3 | ||
|
|
58d5f2a17c | ||
|
|
37f271bd74 | ||
|
|
d770cfc65e | ||
|
|
ab8556790e | ||
|
|
3d89b37cc6 | ||
|
|
78b213321c | ||
|
|
276ba1179f | ||
|
|
0109d166a1 | ||
|
|
779c014256 | ||
|
|
073faf9e10 | ||
|
|
d0e39cf2dd | ||
|
|
f9488a1866 | ||
|
|
d4a3894a9e | ||
|
|
91288b41cd |
+6
-6
@@ -159,13 +159,16 @@ SENDGRID_EMAIL=
|
||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||
|
||||
# Sentry
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
|
||||
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_TRACES_SAMPLE_RATE=
|
||||
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
|
||||
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
|
||||
|
||||
# Formbricks Experience Management Integration
|
||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||
@@ -363,9 +366,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
||||
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# disable setry server source maps
|
||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
|
||||
# api v2
|
||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||
|
||||
@@ -425,7 +425,7 @@ DIRECTORY_IDS_TO_LOG=
|
||||
# Read more about it in the README.md
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||
|
||||
AKISMET_API_KEY=
|
||||
IFFY_API_KEY=
|
||||
|
||||
## Env variables related to avoiding booking failures
|
||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Constructs the WHERE clause for Prisma booking findMany operation.
|
||||
*
|
||||
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
||||
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
||||
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
||||
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
||||
*
|
||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
||||
*/
|
||||
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
|
||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
||||
|
||||
let whereClause = {};
|
||||
|
||||
if (filterByAttendeeEmails) {
|
||||
whereClause = {
|
||||
AND: [
|
||||
userFilter,
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: attendeeEmails },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
whereClause = {
|
||||
...userFilter,
|
||||
};
|
||||
}
|
||||
|
||||
return whereClause;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { Prisma, Booking } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||
import {
|
||||
getAccessibleUsers,
|
||||
retrieveOrgScopedAccessibleUsers,
|
||||
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs the WHERE clause for Prisma booking findMany operation.
|
||||
*
|
||||
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
||||
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
||||
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
||||
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
||||
*
|
||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
||||
*/
|
||||
function buildWhereClause(
|
||||
userId: number | null,
|
||||
attendeeEmails: string[],
|
||||
userIds: number[] = [],
|
||||
userEmails: string[] = []
|
||||
) {
|
||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
||||
|
||||
let whereClause = {};
|
||||
|
||||
if (filterByAttendeeEmails) {
|
||||
whereClause = {
|
||||
AND: [
|
||||
userFilter,
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: attendeeEmails },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
whereClause = {
|
||||
OR: [
|
||||
userFilter,
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: userEmails },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return whereClause;
|
||||
}
|
||||
|
||||
export async function handler(req: NextApiRequest) {
|
||||
const {
|
||||
userId,
|
||||
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
|
||||
? [queryFilterForAttendeeEmails.attendeeEmail]
|
||||
: [];
|
||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||
let userEmailsToFilterBy: string[] = [];
|
||||
|
||||
/** Only admins can query other users */
|
||||
if (isSystemWideAdmin) {
|
||||
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
|
||||
userId,
|
||||
};
|
||||
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
||||
userEmailsToFilterBy = userEmails;
|
||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||
}
|
||||
} else if (isOrganizationOwnerOrAdmin) {
|
||||
let requestedUserIds = [userId];
|
||||
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
|
||||
userId,
|
||||
};
|
||||
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
||||
userEmailsToFilterBy = userEmails;
|
||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||
} else {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
|
||||
if (!user) {
|
||||
throw new HttpError({ message: "User not found", statusCode: 404 });
|
||||
}
|
||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
||||
args.where = buildWhereClause(userId, attendeeEmails, []);
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const data = await prisma.booking.findMany(args);
|
||||
let data: Booking[] = [];
|
||||
|
||||
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
|
||||
const queryOne = prisma.booking.findMany(args);
|
||||
|
||||
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: userEmailsToFilterBy },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (dateFrom) {
|
||||
whereClauseForQueryTwo.startTime = { gte: dateFrom };
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClauseForQueryTwo.endTime = { lte: dateTo };
|
||||
}
|
||||
if (status === "upcoming") {
|
||||
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
|
||||
}
|
||||
|
||||
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
|
||||
...args,
|
||||
where: whereClauseForQueryTwo,
|
||||
};
|
||||
|
||||
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
|
||||
|
||||
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
|
||||
|
||||
const bookingMap = new Map();
|
||||
[...resultOne, ...resultTwo].forEach((booking) => {
|
||||
bookingMap.set(booking.id, booking);
|
||||
});
|
||||
|
||||
const dedupedResults = Array.from(bookingMap.values());
|
||||
|
||||
if (args.orderBy) {
|
||||
let sortField: keyof Booking;
|
||||
let sortDirection: "asc" | "desc" = "asc";
|
||||
|
||||
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
|
||||
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
|
||||
sortField = orderByKey as keyof Booking;
|
||||
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
|
||||
} else {
|
||||
sortField = "id";
|
||||
}
|
||||
|
||||
const sortOrder = sortDirection === "desc" ? -1 : 1;
|
||||
|
||||
dedupedResults.sort((a, b) => {
|
||||
const aValue = a[sortField];
|
||||
const bValue = b[sortField];
|
||||
|
||||
if (aValue < bValue) return -1 * sortOrder;
|
||||
if (aValue > bValue) return 1 * sortOrder;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (args.take !== undefined && args.skip !== undefined) {
|
||||
data = dedupedResults.slice(args.skip, args.skip + args.take);
|
||||
} else {
|
||||
data = dedupedResults;
|
||||
}
|
||||
} else {
|
||||
data = await prisma.booking.findMany(args);
|
||||
}
|
||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
|
||||
console.log("bookings=>", responseData.bookings);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
||||
if (booking.id === 19) {
|
||||
// The team field can be either null or undefined due to nullish() in the schema
|
||||
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date filtering", () => {
|
||||
it("filters bookings by dateFrom", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
dateFrom: "2023-01-01T00:00:00Z",
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date("2023-01-01T00:00:00Z").getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters bookings by dateTo", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
dateTo: "2024-12-31T23:59:59Z",
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
|
||||
new Date("2024-12-31T23:59:59Z").getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sorting and ordering", () => {
|
||||
it("sorts bookings by createdAt in descending order", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
sortBy: "createdAt",
|
||||
order: "desc",
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
|
||||
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple attendee email filtering", () => {
|
||||
it("filters bookings by multiple attendee emails", async () => {
|
||||
const attendeeEmails = ["test1@example.com", "test2@example.com"];
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
attendeeEmail: attendeeEmails,
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
responseData.bookings.forEach((booking) => {
|
||||
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
|
||||
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error cases", () => {
|
||||
it("throws error for invalid status parameter", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
status: "invalid_status",
|
||||
},
|
||||
pagination: DefaultPagination,
|
||||
});
|
||||
|
||||
req.userId = proUser.id;
|
||||
|
||||
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Result merging", () => {
|
||||
it("does not return duplicate bookings when merging results from multiple queries", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
|
||||
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
||||
where: { userId: testUser.id },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {
|
||||
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
|
||||
},
|
||||
pagination: {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
},
|
||||
});
|
||||
|
||||
req.isSystemWideAdmin = true;
|
||||
req.userId = adminUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
|
||||
const bookingIds = responseData.bookings.map((booking) => booking.id);
|
||||
|
||||
const uniqueBookingIds = new Set(bookingIds);
|
||||
|
||||
expect(uniqueBookingIds.size).toBe(bookingIds.length);
|
||||
|
||||
if (uniqueBookingIds.size !== bookingIds.length) {
|
||||
const counts = bookingIds.reduce((acc, id) => {
|
||||
acc[id] = (acc[id] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<number, number>);
|
||||
|
||||
const duplicates = Object.entries(counts)
|
||||
.filter(([_, count]) => count > 1)
|
||||
.map(([id]) => id);
|
||||
|
||||
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||
|
||||
describe("buildWhereClause", () => {
|
||||
it("should return empty object when no filters are provided", () => {
|
||||
const result = buildWhereClause(null, [], []);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should filter by userId when only userId is provided", () => {
|
||||
const userId = 123;
|
||||
const result = buildWhereClause(userId, [], []);
|
||||
expect(result).toEqual({ userId: 123 });
|
||||
});
|
||||
|
||||
it("should filter by userIds when userIds array is provided", () => {
|
||||
const userIds = [1, 2, 3];
|
||||
const result = buildWhereClause(null, [], userIds);
|
||||
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
|
||||
});
|
||||
|
||||
it("should filter by attendee emails when provided", () => {
|
||||
const attendeeEmails = ["test@example.com", "user@example.com"];
|
||||
const result = buildWhereClause(null, attendeeEmails, []);
|
||||
expect(result).toEqual({
|
||||
AND: [
|
||||
{},
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: ["test@example.com", "user@example.com"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should combine userId and attendee email filters", () => {
|
||||
const userId = 123;
|
||||
const attendeeEmails = ["test@example.com"];
|
||||
const result = buildWhereClause(userId, attendeeEmails, []);
|
||||
expect(result).toEqual({
|
||||
AND: [
|
||||
{ userId: 123 },
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: ["test@example.com"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should combine userIds and attendee email filters", () => {
|
||||
const userIds = [1, 2, 3];
|
||||
const attendeeEmails = ["test@example.com"];
|
||||
const result = buildWhereClause(null, attendeeEmails, userIds);
|
||||
expect(result).toEqual({
|
||||
AND: [
|
||||
{ userId: { in: [1, 2, 3] } },
|
||||
{
|
||||
attendees: {
|
||||
some: {
|
||||
email: { in: ["test@example.com"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.185",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.197",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
KyselyModule,
|
||||
RedisModule,
|
||||
TokensModule,
|
||||
BillingModule,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly kyselyReadService: KyselyReadService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly config: ConfigService,
|
||||
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
|
||||
ctx: {
|
||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
kysely: this.kyselyReadService.kysely,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingReferencesRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
|
||||
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
|
||||
|
||||
if (filter?.type) {
|
||||
whereClause.type = filter.type;
|
||||
}
|
||||
|
||||
return this.dbRead.prisma.bookingReference.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
type: true,
|
||||
uid: true,
|
||||
id: true,
|
||||
externalCalendarId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
@@ -17,6 +20,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
@@ -36,6 +40,7 @@ import { Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
KyselyModule,
|
||||
RedisModule,
|
||||
TokensModule,
|
||||
BillingModule,
|
||||
@@ -56,6 +61,7 @@ import { Module } from "@nestjs/common";
|
||||
BookingsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
OutputBookingReferencesService_2024_08_13,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
BookingSeatRepository,
|
||||
@@ -69,6 +75,8 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
ErrorsBookingsService_2024_08_13,
|
||||
BookingReferencesService_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersService: UsersService,
|
||||
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -386,4 +392,30 @@ export class BookingsController_2024_08_13 {
|
||||
data: calendarLinks,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/references")
|
||||
@PlatformPlan("SCALE")
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Booking References' for a booking",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser("id") userId: number,
|
||||
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
|
||||
bookingUid,
|
||||
userId,
|
||||
filter
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookingReferences,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
|
||||
export const BookingReferences = [
|
||||
"google_calendar",
|
||||
"office365_calendar",
|
||||
"daily_video",
|
||||
"google_video",
|
||||
"office365_video",
|
||||
"zoom_video",
|
||||
] as const;
|
||||
|
||||
export class BookingReferencesFilterInput_2024_08_13 {
|
||||
@ApiProperty({
|
||||
description: "Filter booking references by type",
|
||||
required: false,
|
||||
enum: BookingReferences,
|
||||
example: "google_calendar",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(BookingReferences)
|
||||
type?: (typeof BookingReferences)[number];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNumber, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class BookingReference {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The type of the booking reference",
|
||||
})
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The event uid of the booking",
|
||||
})
|
||||
eventUid!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "The id of the calendar the event is created in",
|
||||
nullable: true,
|
||||
})
|
||||
destinationCalendarId!: string | null;
|
||||
|
||||
@IsNumber()
|
||||
@ApiProperty({
|
||||
description: "The id of the booking reference",
|
||||
})
|
||||
id!: number;
|
||||
}
|
||||
|
||||
export class BookingReferencesOutput_2024_08_13 {
|
||||
@ApiProperty({
|
||||
description: "The status of the request, always 'success' for successful responses",
|
||||
example: SUCCESS_STATUS,
|
||||
})
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Booking References",
|
||||
type: [BookingReference],
|
||||
})
|
||||
data!: BookingReference[];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BookingReferencesService_2024_08_13 {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
|
||||
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
|
||||
) {}
|
||||
|
||||
async getBookingReferences(
|
||||
bookingUid: string,
|
||||
userId: number,
|
||||
filter?: BookingReferencesFilterInput_2024_08_13
|
||||
) {
|
||||
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
if (booking.user?.id !== userId) {
|
||||
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
|
||||
}
|
||||
|
||||
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
|
||||
|
||||
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
|
||||
}
|
||||
|
||||
async getOrgBookingReferences(bookingUid: string, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
|
||||
|
||||
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
@@ -78,6 +79,7 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly kyselyReadService: KyselyReadService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
@@ -385,6 +387,7 @@ export class BookingsService_2024_08_13 {
|
||||
ctx: {
|
||||
user,
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
kysely: this.kyselyReadService.kysely,
|
||||
},
|
||||
sort: this.inputService.transformGetBookingsSort(queryParams),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
interface IBookingReference {
|
||||
type: string;
|
||||
uid: string;
|
||||
id: number;
|
||||
externalCalendarId: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OutputBookingReferencesService_2024_08_13 {
|
||||
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
|
||||
return bookingReferences.map((bookingReference) => ({
|
||||
type: bookingReference.type,
|
||||
eventUid: bookingReference.uid,
|
||||
id: bookingReference.id,
|
||||
destinationCalendarId: bookingReference?.externalCalendarId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
SeatedAttendee,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { OnModuleDestroy } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DeduplicateJoinsPlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect } from "kysely";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import type { DB } from "@calcom/kysely/types";
|
||||
|
||||
@Injectable()
|
||||
export class KyselyReadService implements OnModuleDestroy {
|
||||
private logger = new Logger("KyselyReadService");
|
||||
|
||||
public kysely: Kysely<DB>;
|
||||
|
||||
constructor(readonly configService: ConfigService) {
|
||||
const dbUrl = configService.get("db.readUrl", { infer: true });
|
||||
const pool = new Pool({ connectionString: dbUrl });
|
||||
|
||||
// 3. Create the Dialect, passing the configured pool instance
|
||||
const dialect = new PostgresDialect({
|
||||
pool: pool, // Use the pool instance created above
|
||||
});
|
||||
this.kysely = new Kysely<DB>({
|
||||
dialect,
|
||||
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.kysely.destroy();
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { OnModuleDestroy } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Kysely, ParseJSONResultsPlugin, PostgresDialect, DeduplicateJoinsPlugin } from "kysely";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import type { DB } from "@calcom/kysely/types";
|
||||
|
||||
@Injectable()
|
||||
export class KyselyWriteService implements OnModuleDestroy {
|
||||
private logger = new Logger("KyselyWriteService");
|
||||
|
||||
public kysely: Kysely<DB>;
|
||||
|
||||
constructor(readonly configService: ConfigService) {
|
||||
const dbUrl = configService.get("db.writeUrl", { infer: true });
|
||||
const pool = new Pool({ connectionString: dbUrl });
|
||||
|
||||
// 3. Create the Dialect, passing the configured pool instance
|
||||
const dialect = new PostgresDialect({
|
||||
pool: pool, // Use the pool instance created above
|
||||
});
|
||||
this.kysely = new Kysely<DB>({
|
||||
dialect,
|
||||
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.kysely.destroy();
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
import { KyselyWriteService } from "@/modules/kysely/kysely-write.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
providers: [KyselyReadService, KyselyWriteService],
|
||||
exports: [KyselyReadService, KyselyWriteService],
|
||||
})
|
||||
export class KyselyModule {}
|
||||
+196
-10
@@ -6,6 +6,7 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
|
||||
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
|
||||
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
|
||||
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
||||
import { OrgUserAttribute } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
@@ -14,17 +15,16 @@ 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 { Attribute, AttributeOption, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Membership, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Memberships Endpoints", () => {
|
||||
@@ -33,7 +33,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let attributesRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
@@ -49,6 +50,17 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
let userToInviteViaApi: User;
|
||||
|
||||
let textAttribute: Attribute;
|
||||
let multiSelectAttribute: Attribute;
|
||||
let numberAttribute: Attribute;
|
||||
let singleSelectAttribute: Attribute;
|
||||
|
||||
let textAttributeOption: AttributeOption;
|
||||
let multiSelectAttributeOption: AttributeOption;
|
||||
let multiSelectAttributeOption2: AttributeOption;
|
||||
let numberAttributeOption: AttributeOption;
|
||||
let singleSelectAttributeOption: AttributeOption;
|
||||
|
||||
const metadata = {
|
||||
some: "key",
|
||||
};
|
||||
@@ -64,7 +76,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -91,16 +104,42 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
await setupAttributes();
|
||||
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership2 = await membershipsRepositoryFixture.create({
|
||||
membership2 = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
AttributeToUser: {
|
||||
create: [
|
||||
{
|
||||
attributeOption: { connect: { id: textAttributeOption.id } },
|
||||
weight: 100,
|
||||
},
|
||||
{
|
||||
attributeOption: { connect: { id: multiSelectAttributeOption.id } },
|
||||
weight: 100,
|
||||
},
|
||||
{
|
||||
attributeOption: { connect: { id: multiSelectAttributeOption2.id } },
|
||||
weight: null,
|
||||
},
|
||||
{
|
||||
attributeOption: { connect: { id: numberAttributeOption.id } },
|
||||
weight: 100,
|
||||
},
|
||||
{
|
||||
attributeOption: { connect: { id: singleSelectAttributeOption.id } },
|
||||
weight: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -109,6 +148,87 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function setupAttributes() {
|
||||
textAttribute = await attributesRepositoryFixture.create({
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
name: "team",
|
||||
slug: `team-${randomString()}`,
|
||||
enabled: true,
|
||||
usersCanEditRelation: false,
|
||||
isWeightsEnabled: false,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
multiSelectAttribute = await attributesRepositoryFixture.create({
|
||||
team: { connect: { id: org.id } },
|
||||
type: "MULTI_SELECT",
|
||||
name: "skills",
|
||||
slug: `skills-${randomString()}`,
|
||||
enabled: true,
|
||||
usersCanEditRelation: false,
|
||||
isWeightsEnabled: false,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
numberAttribute = await attributesRepositoryFixture.create({
|
||||
team: { connect: { id: org.id } },
|
||||
type: "NUMBER",
|
||||
name: "age",
|
||||
slug: `age-${randomString()}`,
|
||||
enabled: true,
|
||||
usersCanEditRelation: false,
|
||||
isWeightsEnabled: false,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
singleSelectAttribute = await attributesRepositoryFixture.create({
|
||||
team: { connect: { id: org.id } },
|
||||
type: "SINGLE_SELECT",
|
||||
name: "frontend",
|
||||
slug: `frontend-${randomString()}`,
|
||||
enabled: true,
|
||||
usersCanEditRelation: false,
|
||||
isWeightsEnabled: false,
|
||||
isLocked: false,
|
||||
});
|
||||
|
||||
textAttributeOption = await attributesRepositoryFixture.createOption({
|
||||
attribute: { connect: { id: textAttribute.id } },
|
||||
value: "coders",
|
||||
slug: "coders",
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
multiSelectAttributeOption = await attributesRepositoryFixture.createOption({
|
||||
attribute: { connect: { id: multiSelectAttribute.id } },
|
||||
value: "javascript",
|
||||
slug: "javascript",
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
multiSelectAttributeOption2 = await attributesRepositoryFixture.createOption({
|
||||
attribute: { connect: { id: multiSelectAttribute.id } },
|
||||
value: "typescript",
|
||||
slug: "typescript",
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
numberAttributeOption = await attributesRepositoryFixture.createOption({
|
||||
attribute: { connect: { id: numberAttribute.id } },
|
||||
value: "18",
|
||||
slug: "18",
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
singleSelectAttributeOption = await attributesRepositoryFixture.createOption({
|
||||
attribute: { connect: { id: singleSelectAttribute.id } },
|
||||
value: "yes",
|
||||
slug: "yes",
|
||||
isGroup: false,
|
||||
});
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
@@ -139,9 +259,54 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
expect(responseBody.data[1].user.email).toEqual(user2.email);
|
||||
expect(responseBody.data[1].user.username).toEqual(user2.username);
|
||||
expect(responseBody.data[1].teamId).toEqual(org.id);
|
||||
userHasCorrectAttributes(responseBody.data[1].attributes);
|
||||
});
|
||||
});
|
||||
|
||||
function userHasCorrectAttributes(attributes: OrgUserAttribute[]) {
|
||||
expect(attributes.length).toEqual(4);
|
||||
const responseNumberAttribute = attributes.find((attr) => attr.type === "number");
|
||||
const responseSingleSelectAttribute = attributes.find((attr) => attr.type === "singleSelect");
|
||||
const responseMultiSelectAttribute = attributes.find((attr) => attr.type === "multiSelect");
|
||||
const responseTextAttribute = attributes.find((attr) => attr.type === "text");
|
||||
expect(responseNumberAttribute).toEqual({
|
||||
id: numberAttribute.id,
|
||||
name: numberAttribute.name,
|
||||
optionId: numberAttributeOption.id,
|
||||
option: +numberAttributeOption.value,
|
||||
type: "number",
|
||||
});
|
||||
expect(responseSingleSelectAttribute).toEqual({
|
||||
id: singleSelectAttribute.id,
|
||||
name: singleSelectAttribute.name,
|
||||
optionId: singleSelectAttributeOption.id,
|
||||
option: singleSelectAttributeOption.value,
|
||||
type: "singleSelect",
|
||||
});
|
||||
expect(responseMultiSelectAttribute).toEqual({
|
||||
id: multiSelectAttribute.id,
|
||||
name: multiSelectAttribute.name,
|
||||
options: [
|
||||
{
|
||||
optionId: multiSelectAttributeOption2.id,
|
||||
option: multiSelectAttributeOption2.value,
|
||||
},
|
||||
{
|
||||
optionId: multiSelectAttributeOption.id,
|
||||
option: multiSelectAttributeOption.value,
|
||||
},
|
||||
],
|
||||
type: "multiSelect",
|
||||
});
|
||||
expect(responseTextAttribute).toEqual({
|
||||
id: textAttribute.id,
|
||||
name: textAttribute.name,
|
||||
optionId: textAttributeOption.id,
|
||||
option: textAttributeOption.value,
|
||||
type: "text",
|
||||
});
|
||||
}
|
||||
|
||||
it("should get all the memberships of the org paginated", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
|
||||
@@ -182,6 +347,25 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/memberships/${membership2.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetOrgMembership = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membership2.id);
|
||||
expect(responseBody.data.userId).toEqual(user2.id);
|
||||
expect(responseBody.data.role).toEqual("MEMBER");
|
||||
expect(responseBody.data.user.bio).toEqual(bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(metadata);
|
||||
expect(responseBody.data.user.email).toEqual(user2.email);
|
||||
expect(responseBody.data.user.username).toEqual(user2.username);
|
||||
expect(responseBody.data.teamId).toEqual(org.id);
|
||||
userHasCorrectAttributes(responseBody.data.attributes);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the membership of the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/memberships`)
|
||||
@@ -259,7 +443,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
let attributesRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
@@ -277,7 +462,8 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -289,7 +475,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
membership = await membershipsRepositoryFixture.create({
|
||||
membership = await membershipRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
|
||||
+5
-9
@@ -20,7 +20,6 @@ import { GetAllOrgMemberships } from "@/modules/organizations/memberships/output
|
||||
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
||||
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@@ -70,9 +68,7 @@ export class OrganizationsMembershipsController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: memberships.map((membership) =>
|
||||
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
data: memberships,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +84,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
data: membership,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +101,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
data: membership,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +118,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
data: membership,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,7 +140,7 @@ export class OrganizationsMembershipsController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
data: membership,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+59
-6
@@ -4,8 +4,31 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
|
||||
|
||||
export type DbOrgMembership = Awaited<ReturnType<OrganizationsMembershipRepository["findOrgMembership"]>>;
|
||||
|
||||
const attributeToUserSelect = {
|
||||
createdByDSyncId: true,
|
||||
weight: true,
|
||||
attributeOption: {
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
slug: true,
|
||||
attribute: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.AttributeToUserSelect;
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsMembershipRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -16,7 +39,12 @@ export class OrganizationsMembershipRepository {
|
||||
id: membershipId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +54,12 @@ export class OrganizationsMembershipRepository {
|
||||
teamId: organizationId,
|
||||
userId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +69,12 @@ export class OrganizationsMembershipRepository {
|
||||
id: membershipId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,14 +83,24 @@ export class OrganizationsMembershipRepository {
|
||||
create: { ...data, teamId: organizationId },
|
||||
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
|
||||
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
||||
return this.dbWrite.prisma.membership.update({
|
||||
data: { ...data },
|
||||
where: { id: membershipId, teamId: organizationId },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +109,12 @@ export class OrganizationsMembershipRepository {
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
include: {
|
||||
user: { select: MembershipUserSelect },
|
||||
AttributeToUser: {
|
||||
select: attributeToUserSelect,
|
||||
},
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
data!: OrganizationMembershipOutput;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
data!: TeamMembershipOutput;
|
||||
data!: OrganizationMembershipOutput;
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
|
||||
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamMembershipOutput)
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
@IsArray()
|
||||
data!: TeamMembershipOutput[];
|
||||
data!: OrganizationMembershipOutput[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class GetOrgMembership {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
data!: OrganizationMembershipOutput;
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
class BaseAttribute {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class TextAttribute extends BaseAttribute {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
type!: "text";
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
option!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
optionId!: string;
|
||||
}
|
||||
|
||||
export class NumberAttribute extends BaseAttribute {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
type!: "number";
|
||||
|
||||
@IsNumber()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
option!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
optionId!: string;
|
||||
}
|
||||
|
||||
export class SingleSelectAttribute extends BaseAttribute {
|
||||
@IsString()
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
type!: "singleSelect";
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
option!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
optionId!: string;
|
||||
}
|
||||
|
||||
class MultiSelectAttributeOption {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
optionId!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
option!: string;
|
||||
}
|
||||
|
||||
export class MultiSelectAttribute extends BaseAttribute {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
type!: "multiSelect";
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
options!: MultiSelectAttributeOption[];
|
||||
}
|
||||
|
||||
export type OrgUserAttribute = TextAttribute | NumberAttribute | SingleSelectAttribute | MultiSelectAttribute;
|
||||
|
||||
@ApiExtraModels(BaseAttribute, TextAttribute, NumberAttribute, SingleSelectAttribute, MultiSelectAttribute)
|
||||
export class OrganizationMembershipOutput extends TeamMembershipOutput {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
required: true,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(TextAttribute) },
|
||||
{ $ref: getSchemaPath(NumberAttribute) },
|
||||
{ $ref: getSchemaPath(SingleSelectAttribute) },
|
||||
{ $ref: getSchemaPath(MultiSelectAttribute) },
|
||||
],
|
||||
type: "array",
|
||||
})
|
||||
@Type(() => Object)
|
||||
attributes!: OrgUserAttribute[];
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
type: OrganizationMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
@Type(() => OrganizationMembershipOutput)
|
||||
data!: OrganizationMembershipOutput;
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { DbOrgMembership } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import {
|
||||
MultiSelectAttribute,
|
||||
NumberAttribute,
|
||||
OrganizationMembershipOutput,
|
||||
OrgUserAttribute,
|
||||
SingleSelectAttribute,
|
||||
TextAttribute,
|
||||
} from "@/modules/organizations/memberships/outputs/organization-membership.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { groupMembershipAttributes } from "@calcom/platform-libraries";
|
||||
import type { GroupedAttribute } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsMembershipOutputService {
|
||||
getOrgMembershipsOutput(memberships: NonNullable<DbOrgMembership>[]) {
|
||||
return memberships.map((membership) => this.getOrgMembershipOutput(membership));
|
||||
}
|
||||
|
||||
getOrgMembershipOutput(organizationMembership: NonNullable<DbOrgMembership>) {
|
||||
const { AttributeToUser, ...orgMembership } = organizationMembership;
|
||||
|
||||
const groupedMembershipAttributes: GroupedAttribute[] = groupMembershipAttributes(AttributeToUser);
|
||||
const attributesOutput = this.getAttributesOutput(groupedMembershipAttributes);
|
||||
|
||||
return plainToClass(OrganizationMembershipOutput, { ...orgMembership, attributes: attributesOutput });
|
||||
}
|
||||
|
||||
private getAttributesOutput(attributes: GroupedAttribute[]): OrgUserAttribute[] {
|
||||
return attributes.map((attribute) => {
|
||||
switch (attribute.type) {
|
||||
case "TEXT":
|
||||
return this.getTextAttributeOutput(attribute);
|
||||
case "NUMBER":
|
||||
return this.getNumberAttributeOutput(attribute);
|
||||
case "SINGLE_SELECT":
|
||||
return this.getSingleSelectAttributeOutput(attribute);
|
||||
case "MULTI_SELECT":
|
||||
return this.getMultiSelectAttributeOutput(attribute);
|
||||
default:
|
||||
return this.getTextAttributeOutput(attribute);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getTextAttributeOutput(attribute: GroupedAttribute): TextAttribute {
|
||||
return {
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
optionId: attribute.options[0].id,
|
||||
option: attribute.options[0].value,
|
||||
type: "text",
|
||||
};
|
||||
}
|
||||
|
||||
private getNumberAttributeOutput(attribute: GroupedAttribute): NumberAttribute {
|
||||
return {
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
optionId: attribute.options[0].id,
|
||||
option: +attribute.options[0].value,
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
|
||||
private getSingleSelectAttributeOutput(attribute: GroupedAttribute): SingleSelectAttribute {
|
||||
return {
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
optionId: attribute.options[0].id,
|
||||
option: attribute.options[0].value,
|
||||
type: "singleSelect",
|
||||
};
|
||||
}
|
||||
|
||||
private getMultiSelectAttributeOutput(attribute: GroupedAttribute): MultiSelectAttribute {
|
||||
return {
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
options: attribute.options.map((option: GroupedAttribute["options"][number]) => ({
|
||||
optionId: option.id,
|
||||
option: option.value,
|
||||
})),
|
||||
type: "multiSelect",
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
-8
@@ -1,19 +1,30 @@
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
|
||||
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsMembershipService {
|
||||
constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {}
|
||||
constructor(
|
||||
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
|
||||
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
|
||||
) {}
|
||||
|
||||
async getOrgMembership(organizationId: number, membershipId: number) {
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||
organizationId,
|
||||
membershipId
|
||||
);
|
||||
return membership;
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException(
|
||||
`Membership with id ${membershipId} within organization id ${organizationId} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||
@@ -21,7 +32,13 @@ export class OrganizationsMembershipService {
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
return membership;
|
||||
if (!membership) {
|
||||
throw new NotFoundException(
|
||||
`Membership for user with id ${userId} within organization id ${organizationId} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
|
||||
@@ -30,7 +47,7 @@ export class OrganizationsMembershipService {
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return memberships;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipsOutput(memberships);
|
||||
}
|
||||
|
||||
async deleteOrgMembership(organizationId: number, membershipId: number) {
|
||||
@@ -38,7 +55,7 @@ export class OrganizationsMembershipService {
|
||||
organizationId,
|
||||
membershipId
|
||||
);
|
||||
return membership;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
||||
@@ -47,11 +64,11 @@ export class OrganizationsMembershipService {
|
||||
membershipId,
|
||||
data
|
||||
);
|
||||
return membership;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
|
||||
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
|
||||
return membership;
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
||||
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
|
||||
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
@@ -98,6 +99,7 @@ import { Module } from "@nestjs/common";
|
||||
EmailService,
|
||||
OrganizationsMembershipRepository,
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsMembershipOutputService,
|
||||
OrganizationsEventTypesService,
|
||||
InputOrganizationsEventTypesService,
|
||||
OutputOrganizationsEventTypesService,
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||
import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller";
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
ManagedOrganizationsBillingService,
|
||||
OrganizationsRepository,
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsMembershipOutputService,
|
||||
OrganizationsMembershipRepository,
|
||||
ManagedOrganizationsOutputService,
|
||||
],
|
||||
|
||||
+40
-30
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
|
||||
where: {
|
||||
formId: routingForm.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||
it("should not get routing form responses for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: {
|
||||
id: routingFormResponse.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: {
|
||||
id: routingFormResponse2.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+39
-2
@@ -1,13 +1,18 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
@@ -20,7 +25,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
|
||||
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsBookingsController {
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team bookings" })
|
||||
@@ -56,4 +64,33 @@ export class OrganizationsTeamsBookingsController {
|
||||
data: bookings,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/references")
|
||||
@PlatformPlan("SCALE")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Permissions([BOOKING_READ])
|
||||
@UseGuards(
|
||||
ApiAuthGuard,
|
||||
BookingUidGuard,
|
||||
IsOrgGuard,
|
||||
RolesGuard,
|
||||
IsTeamInOrg,
|
||||
PlatformPlanGuard,
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Booking References' for a booking",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid, filter);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookingReferences,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,4 +1,8 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
|
||||
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
|
||||
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
BookingReferencesService_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
BookingsRepository_2024_08_13,
|
||||
OutputBookingReferencesService_2024_08_13,
|
||||
],
|
||||
controllers: [OrganizationsTeamsBookingsController],
|
||||
})
|
||||
export class OrganizationsTeamsBookingsModule {}
|
||||
|
||||
+4
-4
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let routingFormResponseId: string;
|
||||
let routingFormResponseId: number;
|
||||
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
let routingFormId: string;
|
||||
const routingFormResponses = [
|
||||
{
|
||||
id: 1,
|
||||
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
|
||||
formFillerId: `${randomString()}`,
|
||||
response: {
|
||||
"participant-field": {
|
||||
label: "participant",
|
||||
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
const responseData = responseBody.data;
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.length).toEqual(1);
|
||||
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
|
||||
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
||||
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
||||
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
||||
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
|
||||
await routingFormsRepositoryFixture.delete(routingFormId);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
|
||||
+10
-1
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
|
||||
return await this.dbWrite.prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
organizationId: orgId,
|
||||
OR: [
|
||||
{ organizationId: orgId },
|
||||
{
|
||||
profiles: {
|
||||
some: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller";
|
||||
@@ -22,6 +23,7 @@ import { Module } from "@nestjs/common";
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
OrganizationsMembershipService,
|
||||
OrganizationsMembershipOutputService,
|
||||
OrganizationsDelegationCredentialRepository,
|
||||
OrganizationsMembershipRepository,
|
||||
SelectedCalendarsService,
|
||||
|
||||
+97
-12
@@ -181,7 +181,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
title: "frisbee match",
|
||||
slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`,
|
||||
length: 15,
|
||||
metadata: { multipleDuration: [15, 30, 45, 60] },
|
||||
metadata: { multipleDuration: [15, 30, 45, 60, 180] },
|
||||
},
|
||||
user.id
|
||||
);
|
||||
@@ -506,6 +506,65 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
expect(reserveResponseBody.data).toEqual(rest);
|
||||
});
|
||||
|
||||
describe("overlapping slot reservations", () => {
|
||||
it("start of request slot overlaps already existing reserved slot", async () => {
|
||||
// Try to reserve 10:15-11:15 when 10:00-11:00 is taken (60 min event)
|
||||
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 15 }).toISO();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: newSlotStart,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422)
|
||||
.then((response) => {
|
||||
expect(response.body.error.message).toEqual(
|
||||
"This time slot is already reserved by another user. Please choose a different time."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("end of request slot overlaps already existing reserved slot", async () => {
|
||||
// Try to reserve 9:45-10:45 when 10:00-11:00 is taken (60 min event)
|
||||
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).minus({ minutes: 15 }).toISO();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: newSlotStart,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422)
|
||||
.then((response) => {
|
||||
expect(response.body.error.message).toEqual(
|
||||
"This time slot is already reserved by another user. Please choose a different time."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("request slot is inside already existing reserved slot", async () => {
|
||||
// Try to reserve 10:10-11:10 when 10:00-11:00 is taken (60 min event)
|
||||
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 10 }).toISO();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: newSlotStart,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422)
|
||||
.then((response) => {
|
||||
expect(response.body.error.message).toEqual(
|
||||
"This time slot is already reserved by another user. Please choose a different time."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should update a reserved slot and it should not appear in available slots", async () => {
|
||||
// note(Lauris): mock current date to test slots release time
|
||||
const now = "2049-09-05T14:00:00.000Z";
|
||||
@@ -1266,6 +1325,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
|
||||
describe("variable length", () => {
|
||||
let responseReservedVariableSlot: ReserveSlotOutputData_2024_09_04;
|
||||
it("should not be able to reserve a slot for variable length event type with invalid duration", async () => {
|
||||
const slotStartTime = "2050-09-05T10:00:00.000Z";
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
@@ -1279,7 +1339,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
.expect(400);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(
|
||||
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60"
|
||||
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60, 180"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1303,17 +1363,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
|
||||
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
|
||||
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
|
||||
expect(responseReservedSlot.reservationUid).toBeDefined();
|
||||
expect(responseReservedSlot.eventTypeId).toEqual(variableLengthEventType.id);
|
||||
expect(responseReservedSlot.slotStart).toEqual(slotStartTime);
|
||||
expect(responseReservedSlot.slotDuration).toEqual(slotDuration);
|
||||
expect(responseReservedSlot.slotEnd).toEqual(
|
||||
responseReservedVariableSlot = reserveResponseBody.data;
|
||||
expect(responseReservedVariableSlot.reservationUid).toBeDefined();
|
||||
expect(responseReservedVariableSlot.eventTypeId).toEqual(variableLengthEventType.id);
|
||||
expect(responseReservedVariableSlot.slotStart).toEqual(slotStartTime);
|
||||
expect(responseReservedVariableSlot.slotDuration).toEqual(slotDuration);
|
||||
expect(responseReservedVariableSlot.slotEnd).toEqual(
|
||||
DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO()
|
||||
);
|
||||
expect(responseReservedSlot.reservationDuration).toEqual(5);
|
||||
expect(responseReservedVariableSlot.reservationDuration).toEqual(5);
|
||||
|
||||
if (!responseReservedSlot.reservationUid) {
|
||||
if (!responseReservedVariableSlot.reservationUid) {
|
||||
throw new Error("Reserved slot uid is undefined");
|
||||
}
|
||||
|
||||
@@ -1337,16 +1397,41 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
);
|
||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
||||
|
||||
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
|
||||
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(
|
||||
responseReservedVariableSlot.reservationUid
|
||||
);
|
||||
expect(dbSlot).toBeDefined();
|
||||
if (dbSlot) {
|
||||
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
|
||||
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 5 }).toISO();
|
||||
expect(dbReleaseAt).toEqual(expectedReleaseAt);
|
||||
expect(responseReservedSlot.reservationUntil).toEqual(expectedReleaseAt);
|
||||
expect(responseReservedVariableSlot.reservationUntil).toEqual(expectedReleaseAt);
|
||||
}
|
||||
clear();
|
||||
});
|
||||
|
||||
it("request slot contains already existing reserved slot", async () => {
|
||||
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
|
||||
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
|
||||
.minus({ minutes: 15 })
|
||||
.toISO();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId: variableLengthEventType.id,
|
||||
slotStart: newSlotStart,
|
||||
slotDuration: 180,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.set("Authorization", `Bearer cal_test_${apiKeyString}`)
|
||||
.expect(422)
|
||||
.then((response) => {
|
||||
expect(response.body.error.message).toEqual(
|
||||
"This time slot is already reserved by another user. Please choose a different time."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("out of office", () => {
|
||||
|
||||
@@ -124,6 +124,8 @@ export class SlotsService_2024_09_04 {
|
||||
|
||||
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
|
||||
|
||||
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
|
||||
|
||||
if (eventType.userId) {
|
||||
const slot = await this.slotsRepository.createSlot(
|
||||
eventType.userId,
|
||||
@@ -153,6 +155,20 @@ export class SlotsService_2024_09_04 {
|
||||
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
|
||||
}
|
||||
|
||||
private async checkSlotOverlap(eventTypeId: number, startDate: string, endDate: string) {
|
||||
const overlappingReservation = await this.slotsRepository.getOverlappingSlotReservation(
|
||||
eventTypeId,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
if (overlappingReservation) {
|
||||
throw new UnprocessableEntityException(
|
||||
`This time slot is already reserved by another user. Please choose a different time.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validateSlotDuration(eventType: EventType, inputSlotDuration: number) {
|
||||
const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata);
|
||||
if (!eventTypeMetadata?.multipleDuration) {
|
||||
@@ -257,6 +273,8 @@ export class SlotsService_2024_09_04 {
|
||||
|
||||
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
|
||||
|
||||
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
|
||||
|
||||
const slot = await this.slotsRepository.updateSlot(
|
||||
eventType.id,
|
||||
startDate.toISO(),
|
||||
|
||||
@@ -19,6 +19,30 @@ export class SlotsRepository_2024_09_04 {
|
||||
});
|
||||
}
|
||||
|
||||
async getOverlappingSlotReservation(eventTypeId: number, startDate: string, endDate: string) {
|
||||
return this.dbRead.prisma.selectedSlots.findFirst({
|
||||
where: {
|
||||
eventTypeId,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
// Case 1: New slot starts during an existing slot
|
||||
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gt: startDate } },
|
||||
// Case 2: New slot ends during an existing slot
|
||||
{ slotUtcStartDate: { lt: endDate }, slotUtcEndDate: { gte: endDate } },
|
||||
// Case 3: New slot is completely inside an existing slot
|
||||
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gte: endDate } },
|
||||
// Case 4: New slot completely overlaps an existing slot
|
||||
{ slotUtcStartDate: { gte: startDate }, slotUtcEndDate: { lte: endDate } },
|
||||
],
|
||||
},
|
||||
// Only consider non-expired reservations
|
||||
{ releaseAt: { gt: DateTime.utc().toJSDate() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createSlot(
|
||||
userId: number,
|
||||
eventTypeId: number,
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ export class TeamsMembershipsController {
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Patch("/:membershipId")
|
||||
@ApiOperation({ summary: "Create a membership" })
|
||||
@ApiOperation({ summary: "Update membership" })
|
||||
async updateTeamMembership(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number,
|
||||
|
||||
@@ -3282,6 +3282,82 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
|
||||
"summary": "Get 'Booking References' for a booking",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-secret-key",
|
||||
"in": "header",
|
||||
"description": "For platform customers - OAuth client secret key",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-client-id",
|
||||
"in": "header",
|
||||
"description": "For platform customers - OAuth client ID",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter booking references by type",
|
||||
"example": "google_calendar",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google_calendar",
|
||||
"office365_calendar",
|
||||
"daily_video",
|
||||
"google_video",
|
||||
"office365_video",
|
||||
"zoom_video"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Teams / Bookings"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect": {
|
||||
"post": {
|
||||
"operationId": "OrganizationsConferencingController_connectTeamApp",
|
||||
@@ -7448,6 +7524,74 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/bookings/{bookingUid}/references": {
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getBookingReferences",
|
||||
"summary": "Get 'Booking References' for a booking",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
"in": "header",
|
||||
"description": "Must be set to 2024-08-13",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "2024-08-13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter booking references by type",
|
||||
"example": "google_calendar",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google_calendar",
|
||||
"office365_calendar",
|
||||
"daily_video",
|
||||
"google_video",
|
||||
"office365_video",
|
||||
"zoom_video"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Bookings"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
@@ -9563,7 +9707,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers": {
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones": {
|
||||
"get": {
|
||||
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
|
||||
"summary": "Get list of verified phone numbers of an org team.",
|
||||
@@ -9671,7 +9815,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers/{id}": {
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/{id}": {
|
||||
"get": {
|
||||
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneById",
|
||||
"summary": "Get verified phone number of an org team by id.",
|
||||
@@ -11506,7 +11650,7 @@
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "TeamsMembershipsController_updateTeamMembership",
|
||||
"summary": "Create a membership",
|
||||
"summary": "Update membership",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -11855,7 +11999,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/teams/{teamId}/verified-resources/phone-numbers": {
|
||||
"/v2/teams/{teamId}/verified-resources/phones": {
|
||||
"get": {
|
||||
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
|
||||
"summary": "Get list of verified phone numbers of a team.",
|
||||
@@ -11963,7 +12107,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/teams/{teamId}/verified-resources/phone-numbers/{id}": {
|
||||
"/v2/teams/{teamId}/verified-resources/phones/{id}": {
|
||||
"get": {
|
||||
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneById",
|
||||
"summary": "Get verified phone number of a team by id.",
|
||||
@@ -12235,7 +12379,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/verified-resources/phone-numbers": {
|
||||
"/v2/verified-resources/phones": {
|
||||
"get": {
|
||||
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneNumbers",
|
||||
"summary": "Get list of verified phone numbers.",
|
||||
@@ -12327,7 +12471,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/verified-resources/phone-numbers/{id}": {
|
||||
"/v2/verified-resources/phones/{id}": {
|
||||
"get": {
|
||||
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneById",
|
||||
"summary": "Get verified phone number by id.",
|
||||
@@ -14955,8 +15099,7 @@
|
||||
"eightxeight-video",
|
||||
"discord-video",
|
||||
"demodesk-video",
|
||||
"campfire-video",
|
||||
"around-video"
|
||||
"campfire-video"
|
||||
],
|
||||
"example": "cal-video"
|
||||
},
|
||||
@@ -18117,6 +18260,143 @@
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"BaseAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"TextAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"value",
|
||||
"valueId"
|
||||
]
|
||||
},
|
||||
"NumberAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
},
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"value",
|
||||
"valueId"
|
||||
]
|
||||
},
|
||||
"SingleSelectAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"value",
|
||||
"valueId"
|
||||
]
|
||||
},
|
||||
"MultiSelectAttributeValue": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"valueId",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"MultiSelectAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MultiSelectAttributeValue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"values"
|
||||
]
|
||||
},
|
||||
"MembershipUserOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18146,7 +18426,7 @@
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"OrganizationMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -18174,6 +18454,25 @@
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/MembershipUserOutputDto"
|
||||
},
|
||||
"attributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TextAttribute"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NumberAttribute"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SingleSelectAttribute"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/MultiSelectAttribute"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18182,7 +18481,8 @@
|
||||
"teamId",
|
||||
"accepted",
|
||||
"role",
|
||||
"user"
|
||||
"user",
|
||||
"attributes"
|
||||
]
|
||||
},
|
||||
"GetAllOrgMemberships": {
|
||||
@@ -18197,7 +18497,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18247,7 +18547,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18267,7 +18567,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18287,7 +18587,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18326,7 +18626,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -19549,6 +19849,45 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "number"
|
||||
},
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"MEMBER",
|
||||
"OWNER",
|
||||
"ADMIN"
|
||||
]
|
||||
},
|
||||
"disableImpersonation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/MembershipUserOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"userId",
|
||||
"teamId",
|
||||
"accepted",
|
||||
"role",
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"OrgTeamMembershipsOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -21636,7 +21975,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
"type": "number"
|
||||
},
|
||||
"formId": {
|
||||
"type": "string"
|
||||
@@ -22189,8 +22528,7 @@
|
||||
"eightxeight-video",
|
||||
"discord-video",
|
||||
"demodesk-video",
|
||||
"campfire-video",
|
||||
"around-video"
|
||||
"campfire-video"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -24144,6 +24482,55 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"BookingReference": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "The type of the booking reference"
|
||||
},
|
||||
"eventUid": {
|
||||
"type": "string",
|
||||
"description": "The event uid of the booking"
|
||||
},
|
||||
"destinationCalendarId": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The id of the calendar the event is created in"
|
||||
},
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The id of the booking reference"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"eventUid",
|
||||
"destinationCalendarId",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"BookingReferencesOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"description": "The status of the request, always 'success' for successful responses",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Booking References",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BookingReference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreateTeamMembershipInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma, App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Prisma, App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@prisma/client";
|
||||
|
||||
export class RoutingFormsRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
@@ -23,4 +23,10 @@ export class RoutingFormsRepositoryFixture {
|
||||
async delete(routingFormId: App_RoutingForms_Form["id"]) {
|
||||
return this.prismaWriteClient.app_RoutingForms_Form.delete({ where: { id: routingFormId } });
|
||||
}
|
||||
|
||||
async deleteResponse(routingFormResponseId: App_RoutingForms_FormResponse["id"]) {
|
||||
return this.prismaWriteClient.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: routingFormResponseId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const RenderComponentWithSnippet: React.FC<PropsWithChildren<Props>> = (p
|
||||
// },
|
||||
// });
|
||||
return (
|
||||
<div className="border-gray-6 bg-gray-1 rounded-lg border">
|
||||
<div className="border-gray-6 bg-default rounded-lg border">
|
||||
<div className={className("not-prose p-8 xl:p-12", props.className)}>{props.children}</div>
|
||||
|
||||
<div className="bg-gray-3 border-gray-6 flex items-center justify-start border-b border-t p-2">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
|
||||
export const BasicExample = () => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<RenderComponentWithSnippet>
|
||||
<Section>
|
||||
<Section.Header
|
||||
title="Basic Section"
|
||||
description="This is a basic section with a title and description">
|
||||
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
|
||||
</Section.Header>
|
||||
{isOpen && (
|
||||
<Section.Content>
|
||||
<p>This is the main content of the section.</p>
|
||||
</Section.Content>
|
||||
)}
|
||||
</Section>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
};
|
||||
|
||||
export const BasicExampleSnippet = `
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
|
||||
const BasicSection = () => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Section.Header
|
||||
title="Basic Section"
|
||||
description="This is a basic section with a title and description"
|
||||
>
|
||||
<Section.HeaderRight>
|
||||
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
|
||||
</Section.HeaderRight>
|
||||
</Section.Header>
|
||||
{isOpen && (
|
||||
<Section.Content>
|
||||
<p>This is the main content of the section.</p>
|
||||
</Section.Content>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
`;
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Section
|
||||
---
|
||||
import { Section } from "@calcom/ui/components/section"
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render"
|
||||
import { Row } from "@/app/components/row"
|
||||
import { BasicExample } from "./section.basic"
|
||||
import { WithIconExample } from "./section.withIcon"
|
||||
import { SubSectionExample } from "./section.subsection"
|
||||
|
||||
## Basic Usage
|
||||
|
||||
The Section component provides a structured way to organize content with a consistent visual hierarchy. It includes several subcomponents for different parts of the section.
|
||||
|
||||
<BasicExample/>
|
||||
|
||||
## With Icon
|
||||
|
||||
Sections can include an icon in the header to provide visual context.
|
||||
|
||||
<WithIconExample/>
|
||||
|
||||
## SubSections
|
||||
|
||||
Sections can contain subsections for organizing related content in a nested structure.
|
||||
|
||||
<SubSectionExample/>
|
||||
|
||||
## Components
|
||||
|
||||
The Section component is composed of several subcomponents:
|
||||
|
||||
- `Section` - The root container
|
||||
- `Section.Header` - Container for the section header
|
||||
- `Section.Title` - The section title
|
||||
- `Section.Description` - Optional description text
|
||||
- `Section.Content` - The main content area
|
||||
- `Section.SubSection` - A nested section
|
||||
- `Section.SubSectionHeader` - Header for a subsection
|
||||
- `Section.SubSectionContent` - Content area for a subsection
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Input, Switch } from "@calcom/ui/components/form";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
|
||||
export const SubSectionExample = () => {
|
||||
const [isMainOpen, setIsMainOpen] = useState(true);
|
||||
const [isFirstSubsectionOpen, setIsFirstSubsectionOpen] = useState(true);
|
||||
const [isSecondSubsectionOpen, setIsSecondSubsectionOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<RenderComponentWithSnippet>
|
||||
<Section>
|
||||
<Section.Header
|
||||
icon="calendar"
|
||||
title="Section with Subsections"
|
||||
description="This section contains nested subsections">
|
||||
<Switch size="sm" checked={isMainOpen} onCheckedChange={setIsMainOpen} />
|
||||
</Section.Header>
|
||||
{isMainOpen && (
|
||||
<Section.Content>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader icon="zap" title="Automatic Sync (Justify Start)" justify="start">
|
||||
<Switch size="sm" />
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader icon="user" title="Create new records for guests added">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isFirstSubsectionOpen}
|
||||
onCheckedChange={setIsFirstSubsectionOpen}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
{isFirstSubsectionOpen && (
|
||||
<Section.SubSectionContent>
|
||||
<p>This is the content of the first subsection.</p>
|
||||
</Section.SubSectionContent>
|
||||
)}
|
||||
</Section.SubSection>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader icon="settings" title="Open Settings">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isSecondSubsectionOpen}
|
||||
onCheckedChange={setIsSecondSubsectionOpen}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
{isSecondSubsectionOpen && (
|
||||
<Section.SubSectionContent>
|
||||
<div className="flex gap-3 px-3 py-1.5">
|
||||
<div className="text-subtle w-full text-sm font-medium">Field Name</div>
|
||||
<div className="text-subtle w-full text-sm font-medium">Field Type</div>
|
||||
<div className="text-subtle w-full text-sm font-medium">Value</div>
|
||||
<div className="text-subtle w-full text-sm font-medium">When to Write</div>
|
||||
</div>
|
||||
<Section.SubSectionNested>
|
||||
<div className="flex gap-3">
|
||||
<Input value="Customer Name" className="w-full" />
|
||||
<Input value="Text" className="w-full" />
|
||||
<Input value="John Doe" className="w-full" />
|
||||
<Input value="On Create" className="w-full" />
|
||||
</div>
|
||||
</Section.SubSectionNested>
|
||||
</Section.SubSectionContent>
|
||||
)}
|
||||
</Section.SubSection>
|
||||
</Section.Content>
|
||||
)}
|
||||
</Section>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubSectionExampleSnippet = `
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
|
||||
const SectionWithSubsections = () => {
|
||||
const [isMainOpen, setIsMainOpen] = useState(true);
|
||||
const [isFirstSubsectionOpen, setIsFirstSubsectionOpen] = useState(true);
|
||||
const [isSecondSubsectionOpen, setIsSecondSubsectionOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Section.Header
|
||||
icon="calendar"
|
||||
title="Section with Subsections"
|
||||
description="This section contains nested subsections">
|
||||
<Switch size="sm" checked={isMainOpen} onCheckedChange={setIsMainOpen} />
|
||||
</Section.Header>
|
||||
{isMainOpen && (
|
||||
<Section.Content>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader icon="zap" title="Automatic Sync (Justify Start)" justify="start">
|
||||
<Switch size="sm" />
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader icon="user" title="Create new records for guests added">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isFirstSubsectionOpen}
|
||||
onCheckedChange={setIsFirstSubsectionOpen}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
{isFirstSubsectionOpen && (
|
||||
<Section.SubSectionContent>
|
||||
<p>This is the content of the first subsection.</p>
|
||||
</Section.SubSectionContent>
|
||||
)}
|
||||
</Section.SubSection>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader icon="settings" title="Open Settings">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isSecondSubsectionOpen}
|
||||
onCheckedChange={setIsSecondSubsectionOpen}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
{isSecondSubsectionOpen && (
|
||||
<Section.SubSectionContent>
|
||||
<div className="flex gap-3 px-3 py-1.5">
|
||||
<div className="text-subtle w-full text-sm font-medium">Field Name</div>
|
||||
<div className="text-subtle w-full text-sm font-medium">Field Type</div>
|
||||
<div className="text-subtle w-full text-sm font-medium">Value</div>
|
||||
<div className="text-subtle w-full text-sm font-medium">When to Write</div>
|
||||
</div>
|
||||
<Section.SubSectionNested>
|
||||
<div className="flex gap-3">
|
||||
<Input value="Customer Name" className="w-full" />
|
||||
<Input value="Text" className="w-full" />
|
||||
<Input value="John Doe" className="w-full" />
|
||||
<Input value="On Create" className="w-full" />
|
||||
</div>
|
||||
</Section.SubSectionNested>
|
||||
</Section.SubSectionContent>
|
||||
)}
|
||||
</Section.SubSection>
|
||||
</Section.Content>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
`;
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
|
||||
export const WithIconExample = () => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<RenderComponentWithSnippet>
|
||||
<Section>
|
||||
<Section.Header
|
||||
icon="calendar"
|
||||
title="Section with Icon"
|
||||
description="This section includes an icon in the header. Toggle to see auto-animation.">
|
||||
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
|
||||
</Section.Header>
|
||||
{isOpen && (
|
||||
<Section.Content>
|
||||
<p>
|
||||
This section demonstrates how to include an icon in the header and uses FormKit auto-animate for
|
||||
smooth transitions when toggling content visibility.
|
||||
</p>
|
||||
</Section.Content>
|
||||
)}
|
||||
</Section>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithIconExampleSnippet = `
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
|
||||
const SectionWithIcon = () => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Section.Header
|
||||
icon="calendar"
|
||||
title="Section with Icon"
|
||||
description="This section includes an icon in the header. Toggle to see auto-animation.">
|
||||
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
|
||||
</Section.Header>
|
||||
{isOpen && (
|
||||
<Section.Content>
|
||||
<p>
|
||||
This section demonstrates how to include an icon in the header and uses FormKit auto-animate for
|
||||
smooth transitions when toggling content visibility.
|
||||
</p>
|
||||
</Section.Content>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
`;
|
||||
@@ -1,5 +1,14 @@
|
||||
# @calcom/web
|
||||
|
||||
## 5.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @calcom/embed-core@1.5.3
|
||||
- @calcom/embed-react@1.5.3
|
||||
- @calcom/embed-snippet@1.3.3
|
||||
|
||||
## 4.8.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsPage from "~/insights/insights-view";
|
||||
|
||||
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const insightsEnabled = await getFeatureFlag(prisma, "insights");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
|
||||
|
||||
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const insightsEnabled = await getFeatureFlag(prisma, "insights");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsRoutingPage from "~/insights/insights-routing-view";
|
||||
|
||||
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const insightsEnabled = await getFeatureFlag(prisma, "insights");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
|
||||
+42
-18
@@ -2,22 +2,29 @@
|
||||
|
||||
## Setting up Delegation Credential for Google Calendar API
|
||||
|
||||
Step 1: Create a Google Cloud Project
|
||||
Step 0: Create a Workspace Platform(to be done once for the Cal.com instance, by the Cal.com instance admin)
|
||||
|
||||
- Create a Workspace Platform through admin interface at <https://app.cal.com/settings/admin/workspace-platforms>
|
||||
- Use slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
|
||||
- Use "Google" as the name of the workspace platform. Change it as per your liking.
|
||||
- Optionally provide description for the workspace platform.
|
||||
|
||||
Step 1: Create a Google Cloud Project or use existing one(to be done once for the Cal.com instance)
|
||||
|
||||
Before you can create a service account, you'll need to set up a Google Cloud project.
|
||||
|
||||
1. Create a Google Cloud Project:
|
||||
- Go to the Google Cloud Console
|
||||
- Select Create Project
|
||||
- Give your project a name and select your billing account (if applicable)
|
||||
- Click Create
|
||||
1. Go to the Google Cloud Console
|
||||
2. Select Create Project
|
||||
3. Give your project a name and select your billing account (if applicable)
|
||||
4. Click Create
|
||||
2. Enable the Google Calendar API:
|
||||
1. Go to the Google Cloud Console
|
||||
2. Select API & Services → Library
|
||||
3. Search for "Google Calendar API"
|
||||
4. Click Enable
|
||||
|
||||
Step 2: Create a Service Account
|
||||
Step 2: Create a Service Account in Google Cloud Console(to be done for every organization)
|
||||
|
||||
A service account is needed to act on behalf of users
|
||||
|
||||
@@ -26,21 +33,38 @@ A service account is needed to act on behalf of users
|
||||
2. Create a New Service Account:
|
||||
- Click on Create Service Account
|
||||
- Give your service account a name and description
|
||||
- Click Create and Continue
|
||||
- Click Create and Continue(Optional steps can be skipped)
|
||||
3. Download the Service Account Key JSON file
|
||||
|
||||
Step 3: To Be taken by Cal.com instance admin:
|
||||
Step 3: Create Delegation Credential(To Be taken by Cal.com instance admin):
|
||||
|
||||
- Create a Workspace Platform with slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
|
||||
- Impersonate the organization owner and go to https://app.cal.com/settings/organizations/delegation-credential to create a Delegation Credential
|
||||
|
||||
Last Step (To Be Taken By Cal.com organization Owner/Admin): Assign Specific API Permissions via OAuth Scopes:
|
||||
- Create Delegation Credential with workspace platform "google"
|
||||
- User must be a member of the Google Workspace to be able to enable Delegation Credential as there is a validation if the user's calendar can be accessed through the service account
|
||||
- Get the Client ID from there
|
||||
- Go to your Google Admin Console (admin-google-com)
|
||||
- Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation
|
||||
- Here, you'll authorize the Client ID(Unique ID) to access the Google Calendar API
|
||||
- Add the necessary API scopes for Google Calendar(Full access to Google Calendar)
|
||||
<https://www.googleapis.com/auth/calendar>
|
||||
- Use domain as "acme.com" if @acme.com is the email address for your Google Workspace
|
||||
- Choose Workspace Platform as "Google"
|
||||
- Add Service Account Key JSON file(obtained in Step 2)
|
||||
- Click on "Create" button
|
||||
|
||||
Step 4: Copy the Client ID and OAuth Scope (To Be Taken By Cal.com organization Owner/Admin in Cal.com):
|
||||
|
||||
- Go to https://app.cal.com/settings/organizations/delegation-credential
|
||||
- Copy the Client ID for your Google Workspace domain(e.g. acme.com)
|
||||
- Client ID is a number like 123456789012345678901 that
|
||||
|
||||
Step 5: Add Client ID under Domain-Wide Delegation (To Be taken By Google Workspace Admin):
|
||||
|
||||
- Go to your Google Admin Console(admin.google.com)
|
||||
- Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation
|
||||
- Here, you'll authorize the Client ID to access the Google Calendar API
|
||||
- Add the following API scope for Google Calendar(Full access to Google Calendar. We use it to read freebusy time and create/update events in the members' calendars)
|
||||
- `https://www.googleapis.com/auth/calendar`
|
||||
|
||||
Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/Admin in Cal.com):
|
||||
|
||||
- Prerequisite: The owner/admin must be part of the Google Workspace to enable Delegation Credential
|
||||
- Go to https://app.cal.com/settings/organizations/delegation-credential
|
||||
- Enable Delegation Credential
|
||||
- If you have added the Client ID for correct Google Workspace, the Delegation Credential would be enabled, otherwise you would see an error message, that should help and contact support if you still face issues.
|
||||
|
||||
## Onboarding Improvements
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @description This route is used to create and delete Credential records for all members of an organization that has delegation credentials enabled.
|
||||
*
|
||||
* It also deletes Credential records for all members of an organization that has delegation credentials disabled.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @description This route is used to create SelectedCalendar records based on Credential records corresponding to Delegation Credentials
|
||||
*
|
||||
* It works in conjunction with `/api/cron/credentials` route(which creates the Credential records for all the members of an organization that has delegation credentials enabled)
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { NextResponse } from "next/server";
|
||||
import * as pjson from "package.json";
|
||||
import packageJson from "package.json";
|
||||
|
||||
async function getHandler() {
|
||||
return NextResponse.json({ version: pjson.version });
|
||||
return NextResponse.json({ version: packageJson.version });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The added config here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
beforeSend(event) {
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
@@ -7,3 +9,5 @@ export async function register() {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
|
||||
@@ -52,8 +52,8 @@ describe("getThemeProviderProps", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
...appThemeExpectedProps,
|
||||
storageKey: undefined,
|
||||
key: undefined,
|
||||
storageKey: "forcedThemeKey",
|
||||
key: "forcedThemeKey",
|
||||
forcedTheme: "light",
|
||||
enableSystem: false,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { EmbedTheme } from "@calcom/features/embed/lib/constants";
|
||||
|
||||
@@ -92,13 +93,28 @@ export function getThemeProviderProps({
|
||||
searchParams: ReadonlyURLSearchParams | null;
|
||||
}) {
|
||||
const isBookingPage = props.isBookingPage;
|
||||
|
||||
const themeSupport = isBookingPage
|
||||
? ThemeSupport.Booking
|
||||
: props.isThemeSupported === false
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const themeQueryParam = searchParams?.get("theme") ?? "";
|
||||
const themeParsed = z.enum(["light", "dark", "system", "auto"]).safeParse(themeQueryParam);
|
||||
const isWrongThemeValue = themeQueryParam.length > 0 && !themeParsed.success;
|
||||
const forcedTheme = themeSupport === ThemeSupport.None || isWrongThemeValue ? "light" : undefined;
|
||||
if (forcedTheme) {
|
||||
return {
|
||||
key: "forcedThemeKey",
|
||||
storageKey: "forcedThemeKey",
|
||||
forcedTheme,
|
||||
attribute: "class",
|
||||
nonce: props.nonce,
|
||||
enableColorScheme: false,
|
||||
enableSystem: themeSupport !== ThemeSupport.None,
|
||||
};
|
||||
}
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const themeBasis = pathname ? getUniqueIdentifierForBookingPage({ pathname }) : null;
|
||||
const isThemeBasisRequired = isBookingPageThemeSupportRequired || isEmbedMode;
|
||||
@@ -109,8 +125,6 @@ export function getThemeProviderProps({
|
||||
}
|
||||
|
||||
const appearanceIdSuffix = themeBasis ? `:${themeBasis}` : "";
|
||||
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
|
||||
const themeQueryParam = searchParams?.get("theme");
|
||||
const embedExplicitlySetThemeSuffix =
|
||||
isEmbedMode && themeQueryParam && themeQueryParam !== EmbedTheme.auto ? `:${themeQueryParam}` : "";
|
||||
|
||||
@@ -126,8 +140,6 @@ export function getThemeProviderProps({
|
||||
|
||||
const themeProviderProps = {
|
||||
storageKey,
|
||||
// Can force a theme through this, no matter what system preference is or storage value is
|
||||
forcedTheme,
|
||||
nonce: props.nonce,
|
||||
enableColorScheme: false,
|
||||
// Enables theme switching based on system preference if true
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { GetServerSidePropsResult } from "next";
|
||||
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
||||
const organizations = await getFeatureFlag(prisma, "organizations");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
// Check if organizations are enabled
|
||||
if (!organizations) {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
@@ -24,9 +24,11 @@ const querySchema = z.object({
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const emailVerificationEnabled = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"email-verification"
|
||||
);
|
||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||
|
||||
const token = z.string().optional().parse(ctx.query.token);
|
||||
const redirectUrlData = z
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getOrganizationSettings,
|
||||
getVerifiedDomain,
|
||||
} from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
@@ -71,7 +71,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
|
||||
log.debug("getServerSideProps", {
|
||||
isOrgProfile,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
updateMeetingTokenIfExpired,
|
||||
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
||||
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
|
||||
import { isENVDev } from "@calcom/lib/env";
|
||||
@@ -135,6 +136,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const videoReference = getCalVideoReference(bookingObj.references);
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const displayLogInOverlay = profile?.organizationId
|
||||
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
||||
: false;
|
||||
|
||||
return {
|
||||
props: {
|
||||
meetingUrl: videoReference.meetingUrl ?? "",
|
||||
@@ -153,6 +159,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoLogo,
|
||||
displayLogInOverlay,
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useCallback, useState } from "react";
|
||||
|
||||
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
|
||||
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import { NewScheduleButton } from "@calcom/features/schedules/components/NewScheduleButton";
|
||||
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
@@ -152,28 +152,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
slug: eventType.team?.slug || eventType.users[0]?.username || null,
|
||||
};
|
||||
|
||||
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
|
||||
await handleSeatsEventTypeOnBooking(
|
||||
eventType,
|
||||
bookingInfo,
|
||||
seatReferenceUid,
|
||||
session?.user.id === eventType.userId
|
||||
);
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
bookingId: bookingInfo.id,
|
||||
},
|
||||
select: {
|
||||
success: true,
|
||||
refunded: true,
|
||||
currency: true,
|
||||
amount: true,
|
||||
paymentOption: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userId = session?.user?.id;
|
||||
|
||||
const checkIfUserIsHost = (userId?: number | null) => {
|
||||
@@ -194,6 +172,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const isLoggedInUserHost = checkIfUserIsHost(userId);
|
||||
|
||||
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
|
||||
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, isLoggedInUserHost);
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
bookingId: bookingInfo.id,
|
||||
},
|
||||
select: {
|
||||
success: true,
|
||||
refunded: true,
|
||||
currency: true,
|
||||
amount: true,
|
||||
paymentOption: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoggedInUserHost) {
|
||||
// Removing hidden fields from responses
|
||||
for (const key in bookingInfo.responses) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function MaintenancePage() {
|
||||
return (
|
||||
<div className="bg-subtle flex h-screen">
|
||||
<div className="bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
||||
<h1 className="text-emphasis text-2xl font-medium">{t("down_for_maintenance")}</h1>
|
||||
<h1 className="text-emphasis text-2xl font-medium">{t("under_maintenance")}</h1>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("maintenance_message")}</p>
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,15 @@ import { useState, useEffect, useRef } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
|
||||
@@ -22,7 +25,15 @@ import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function JoinCall(props: PageProps) {
|
||||
const { meetingUrl, meetingPassword, booking, hasTeamPlan, calVideoLogo } = props;
|
||||
const {
|
||||
meetingUrl,
|
||||
meetingPassword,
|
||||
booking,
|
||||
hasTeamPlan,
|
||||
calVideoLogo,
|
||||
displayLogInOverlay,
|
||||
loggedInUserName,
|
||||
} = props;
|
||||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,6 +61,7 @@ export default function JoinCall(props: PageProps) {
|
||||
height: "100%",
|
||||
},
|
||||
url: meetingUrl,
|
||||
userName: loggedInUserName ? loggedInUserName ?? undefined : undefined,
|
||||
...(typeof meetingPassword === "string" && { token: meetingPassword }),
|
||||
...(hasTeamPlan && {
|
||||
customTrayButtons: {
|
||||
@@ -112,6 +124,8 @@ export default function JoinCall(props: PageProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{displayLogInOverlay && <LogInOverlay isLoggedIn={!!loggedInUserName} bookingUid={booking.uid} />}
|
||||
|
||||
<VideoMeetingInfo booking={booking} />
|
||||
</DailyProvider>
|
||||
);
|
||||
@@ -187,6 +201,58 @@ function ProgressBar(props: ProgressBarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface LogInOverlayProps {
|
||||
isLoggedIn: boolean;
|
||||
bookingUid: string;
|
||||
}
|
||||
|
||||
export function LogInOverlay(props: LogInOverlayProps) {
|
||||
const { t } = useLocale();
|
||||
const { isLoggedIn, bookingUid } = props;
|
||||
const [open, setOpen] = useState(!isLoggedIn);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
title={t("join_video_call")}
|
||||
description={t("choose_how_you_d_like_to_join_call")}
|
||||
className="bg-black text-white sm:max-w-[480px]">
|
||||
<div className="pb-8">
|
||||
<div className="space-y-8">
|
||||
<Button color="primary" className="mt-4 w-full justify-center " onClick={() => setOpen(false)}>
|
||||
{t("continue_as_guest")}
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-black px-4 text-sm text-gray-400">{t("or")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-lg font-semibold text-white">{t("sign_in_to_cal_com")}</h4>
|
||||
<p className="text-sm text-gray-300">{t("track_your_meetings")}</p>
|
||||
<Button
|
||||
color="primary"
|
||||
className="mt-4 w-full justify-center"
|
||||
onClick={() =>
|
||||
(window.location.href = `${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL}/video/${bookingUid}`)
|
||||
}>
|
||||
<Icon name="external-link" className="mr-2 h-4 w-4" />
|
||||
{t("log_in_to_cal_com")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoMeetingInfo {
|
||||
booking: PageProps["booking"];
|
||||
}
|
||||
|
||||
+1
-45
@@ -1,9 +1,6 @@
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const os = require("os");
|
||||
const englishTranslation = require("./public/static/locales/en/common.json");
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
const { version } = require("./package.json");
|
||||
const {
|
||||
i18n: { locales },
|
||||
@@ -190,9 +187,8 @@ const nextConfig = {
|
||||
experimental: {
|
||||
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
||||
optimizePackageImports: ["@calcom/ui"],
|
||||
turbo: {},
|
||||
},
|
||||
productionBrowserSourceMaps: process.env.SENTRY_DISABLE_CLIENT_SOURCE_MAPS === "0",
|
||||
productionBrowserSourceMaps: true,
|
||||
/* We already do type check on GH actions */
|
||||
typescript: {
|
||||
ignoreBuildErrors: !!process.env.CI,
|
||||
@@ -228,10 +224,6 @@ const nextConfig = {
|
||||
},
|
||||
webpack: (config, { webpack, buildId, isServer }) => {
|
||||
if (isServer) {
|
||||
if (process.env.SENTRY_DISABLE_SERVER_SOURCE_MAPS === "1") {
|
||||
config.devtool = false;
|
||||
}
|
||||
|
||||
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
@@ -250,27 +242,6 @@ const nextConfig = {
|
||||
})
|
||||
);
|
||||
|
||||
config.plugins.push(
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "../../packages/app-store/**/static/**",
|
||||
to({ context, absoluteFilename }) {
|
||||
// Adds compatibility for windows path
|
||||
if (os.platform() === "win32") {
|
||||
const absoluteFilenameWin = absoluteFilename.replaceAll("\\", "/");
|
||||
const contextWin = context.replaceAll("\\", "/");
|
||||
const appName = /app-store\/(.*)\/static/.exec(absoluteFilenameWin);
|
||||
return Promise.resolve(`${contextWin}/public/app-store/${appName[1]}/[name][ext]`);
|
||||
}
|
||||
const appName = /app-store\/(.*)\/static/.exec(absoluteFilename);
|
||||
return Promise.resolve(`${context}/public/app-store/${appName[1]}/[name][ext]`);
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
config.plugins.push(new webpack.DefinePlugin({ "process.env.BUILD_ID": JSON.stringify(buildId) }));
|
||||
|
||||
config.resolve.fallback = {
|
||||
@@ -714,19 +685,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
plugins.push((nextConfig) =>
|
||||
withSentryConfig(nextConfig, {
|
||||
autoInstrumentServerFunctions: false,
|
||||
hideSourceMaps: true,
|
||||
// disable source map generation for the server code
|
||||
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
|
||||
silent: false,
|
||||
sourcemaps: {
|
||||
disable: process.env.SENTRY_DISABLE_SERVER_SOURCE_MAPS === "1",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.2.9",
|
||||
"version": "5.2.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"analyze:server": "BUNDLE_ANALYZE=server next build",
|
||||
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "yarn copy-static && next dev --turbopack",
|
||||
"dev:cron": "ts-node cron-tester.ts",
|
||||
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
|
||||
"dx": "yarn dev",
|
||||
"test-codegen": "yarn playwright codegen http://localhost:3000",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"type-check:ci": "tsc-absolute --pretty --noEmit",
|
||||
"build": "next build",
|
||||
"sentry:release": "NODE_OPTIONS='--max-old-space-size=6144' node scripts/create-sentry-release.js",
|
||||
"copy-static": "node scripts/copy-app-store-static.js",
|
||||
"build": "yarn copy-static && next build && yarn sentry:release",
|
||||
"start": "next start",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
@@ -69,7 +71,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": "^9.8.0",
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
@@ -179,10 +181,10 @@
|
||||
"@types/stripe": "^8.0.417",
|
||||
"@types/uuid": "8.3.1",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"cron": "^3.1.7",
|
||||
"detect-port": "^1.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"glob": "^10.3.10",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextApiResponse } from "next";
|
||||
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -21,8 +21,8 @@ async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
||||
// Still allow signups if there is a team invite
|
||||
if (token) return;
|
||||
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
|
||||
throw new HttpError({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
|
||||
|
||||
export default defaultHandler({
|
||||
OPTIONS: Promise.resolve({
|
||||
default: defaultResponder(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, cache-control, pragma");
|
||||
res.status(204).end();
|
||||
}),
|
||||
}),
|
||||
POST: Promise.resolve({
|
||||
default: defaultResponder(async (req, res) => {
|
||||
// getRoutedUrl has more detailed schema validation, we do a basic one here.
|
||||
const params = req.body;
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
const routedUrlData = await getRoutedUrl({ req, query: { ...params } });
|
||||
if (routedUrlData?.notFound) {
|
||||
return res.status(404).json({ status: "error", data: { message: "Form not found" } });
|
||||
}
|
||||
|
||||
if (routedUrlData?.redirect?.destination) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ status: "success", data: { redirect: routedUrlData.redirect.destination } });
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.errorMessage) {
|
||||
return res.status(400).json({ status: "error", data: { message: routedUrlData.props.errorMessage } });
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.message) {
|
||||
return res.status(200).json({ status: "success", data: { message: routedUrlData.props.message } });
|
||||
}
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ status: "error", data: { message: "Neither Route nor custom message found." } });
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
|
||||
|
||||
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
|
||||
export default function Router({ form, message, errorMessage }: inferSSRProps<typeof getServerSideProps>) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -17,7 +17,7 @@ export default function Router({ form, message }: inferSSRProps<typeof getServer
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
<div className="text-default bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
|
||||
<div>{message}</div>
|
||||
<div>{message || errorMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "../../lib/fixtures";
|
||||
import type { TApp } from "./types";
|
||||
|
||||
// Around is not available in the app store anymore
|
||||
const APP: TApp = {
|
||||
slug: "around",
|
||||
type: "integrations:around_video",
|
||||
@@ -11,7 +12,8 @@ const APP: TApp = {
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("check non-oAuth link-based conferencing apps", () => {
|
||||
// TODO: remove skip once more apps are added
|
||||
test.skip("check non-oAuth link-based conferencing apps", () => {
|
||||
test(`check conferencing app: ${APP.slug} by skipping the configure step`, async ({
|
||||
appsPage,
|
||||
page,
|
||||
@@ -24,7 +26,7 @@ test.describe("check non-oAuth link-based conferencing apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("check non-oAuth link-based conferencing apps using the new flow", () => {
|
||||
test.skip("check non-oAuth link-based conferencing apps using the new flow", () => {
|
||||
test(`can add ${APP.slug} app and book with it`, async ({ appsPage, page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
@@ -72,7 +73,7 @@ test.describe("Bookings", () => {
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await page.locator('[data-testid="add-filter-button"]').click();
|
||||
@@ -350,7 +351,7 @@ test.describe("Bookings", () => {
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await page.locator('[data-testid="add-filter-button"]').click();
|
||||
@@ -457,7 +458,7 @@ test.describe("Bookings", () => {
|
||||
const bookingsGetResponse1 = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto("/bookings/upcoming");
|
||||
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse1;
|
||||
|
||||
await page.locator('[data-testid="add-filter-button"]').click();
|
||||
@@ -470,6 +471,95 @@ test.describe("Bookings", () => {
|
||||
|
||||
await expect(page.locator('[data-testid="booking-item"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test.describe("Filter Dropdown Item Search", () => {
|
||||
const filterItemsConfig = [
|
||||
{ key: "eventTypeId", name: "Event Type", testId: "add-filter-item-eventTypeId" },
|
||||
{ key: "teamId", name: "Team", testId: "add-filter-item-teamId" },
|
||||
{ key: "userId", name: "Member", testId: "add-filter-item-userId" },
|
||||
{ key: "attendeeName", name: "Attendees Name", testId: "add-filter-item-attendeeName" },
|
||||
{ key: "attendeeEmail", name: "Attendee Email", testId: "add-filter-item-attendeeEmail" },
|
||||
{ key: "dateRange", name: "Date Range", testId: "add-filter-item-dateRange" },
|
||||
];
|
||||
const searchInputSelector = "[cmdk-input]";
|
||||
|
||||
const getFilterItemLocator = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]`);
|
||||
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
await page.locator('[data-testid="add-filter-button"]').click();
|
||||
await expect(page.locator(searchInputSelector)).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show all filter items initially and after clearing search", async ({ page }) => {
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
|
||||
// Initial check: all defined filter items should be visible
|
||||
for (const item of filterItemsConfig) {
|
||||
await expect(
|
||||
getFilterItemLocator(page, item.testId),
|
||||
`Item ${item.name} should be visible initially`
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Type something and then clear the search
|
||||
await searchInput.fill("Some text");
|
||||
await searchInput.clear();
|
||||
|
||||
// After clearing: all defined filter items should be visible again
|
||||
for (const item of filterItemsConfig) {
|
||||
await expect(
|
||||
getFilterItemLocator(page, item.testId),
|
||||
`Item ${item.name} should be visible after clearing search`
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("search should be case-insensitive", async ({ page }) => {
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
|
||||
// Search for "member" (lowercase)
|
||||
await searchInput.fill("member");
|
||||
await expect(getFilterItemLocator(page, "add-filter-item-userId")).toBeVisible();
|
||||
await expect(getFilterItemLocator(page, "add-filter-item-eventTypeId")).toBeHidden();
|
||||
await expect(getFilterItemLocator(page, "add-filter-item-teamId")).toBeHidden();
|
||||
});
|
||||
|
||||
test("should individually find each filter item by its full name", async ({ page }) => {
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
|
||||
for (const targetItem of filterItemsConfig) {
|
||||
await searchInput.fill(targetItem.name);
|
||||
|
||||
// Check that the target item is visible
|
||||
await expect(
|
||||
getFilterItemLocator(page, targetItem.testId),
|
||||
`Searching for "${targetItem.name}", item "${targetItem.name}" should be visible`
|
||||
).toBeVisible();
|
||||
|
||||
await searchInput.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test("should show no items for a non-matching search term", async ({ page }) => {
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
const nonExistentTerm = "NonExistentFilterXYZ123";
|
||||
await searchInput.fill(nonExistentTerm);
|
||||
|
||||
for (const item of filterItemsConfig) {
|
||||
await expect(
|
||||
getFilterItemLocator(page, item.testId),
|
||||
`Item ${item.name} should be hidden for non-matching term '${nonExistentTerm}'`
|
||||
).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createBooking({
|
||||
|
||||
@@ -23,6 +23,11 @@ test.describe("hash my url", () => {
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.locator("ul[data-testid=event-types] > li a").first().click();
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page"
|
||||
); // fix the race condition
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
|
||||
// We wait for the page to load
|
||||
await page.locator(".primary-navigation >> text=Advanced").click();
|
||||
// ignore if it is already checked, and click if unchecked
|
||||
|
||||
@@ -50,16 +50,13 @@ test.describe("2FA Tests", async () => {
|
||||
secret: secret!,
|
||||
});
|
||||
|
||||
// FIXME: this passes even when switch is not checked, compare to test
|
||||
// below which checks for data-state="checked" and works as expected
|
||||
await page.waitForSelector(`[data-testid=two-factor-switch]`);
|
||||
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
||||
await expect(page.getByTestId("backup-codes-download")).toBeVisible();
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
await test.step("Logout", async () => {
|
||||
await page.goto("/auth/logout");
|
||||
await users.logout();
|
||||
});
|
||||
|
||||
await test.step("Login with 2FA enabled", async () => {
|
||||
|
||||
@@ -295,6 +295,167 @@ test.describe("Bookings", () => {
|
||||
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
|
||||
await page.waitForSelector("[data-testid=day]");
|
||||
});
|
||||
|
||||
test("Round robin event type properly rotates hosts", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
const eventHostsObj = [...teamMatesObj, { name: "pro-user" }];
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj });
|
||||
const firstHost = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(firstHost).not.toBeNull();
|
||||
|
||||
// Second booking - should get a different host
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
const secondHost = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(secondHost).not.toBeNull();
|
||||
expect(secondHost).not.toBe(firstHost);
|
||||
|
||||
// Third booking - should get a different host
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
const thirdHost = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(thirdHost).not.toBeNull();
|
||||
expect(thirdHost).not.toBe(firstHost);
|
||||
expect(thirdHost).not.toBe(secondHost);
|
||||
|
||||
// Fourth booking - should get a different host
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
const fourthHost = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(fourthHost).not.toBeNull();
|
||||
expect(fourthHost).not.toBe(firstHost);
|
||||
expect(fourthHost).not.toBe(secondHost);
|
||||
expect(fourthHost).not.toBe(thirdHost);
|
||||
|
||||
// Verify all hosts were used
|
||||
const allHosts = [firstHost, secondHost, thirdHost, fourthHost];
|
||||
const uniqueHosts = new Set(allHosts);
|
||||
expect(uniqueHosts.size).toBe(4);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("Round robin event type with confirmation required handles cancellation and rebooking correctly with the same details and slot", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
const eventHostsObj = [...teamMatesObj, { name: "pro-user" }];
|
||||
|
||||
// Enable confirmation required for the event
|
||||
await owner.apiLogin();
|
||||
await page.goto(`/event-types/${teamEvent.id}?tabName=advanced`);
|
||||
await page.getByTestId("requires-confirmation").click();
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
await page.waitForResponse((response) => response.url().includes("/api/trpc/eventTypes/update"));
|
||||
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj });
|
||||
const firstHost = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(firstHost).not.toBeNull();
|
||||
|
||||
const bookingUid = page.url().split("/booking/")[1];
|
||||
expect(bookingUid).not.toBeNull();
|
||||
|
||||
// Login as the host to cancel the booking
|
||||
await page.goto("/auth/logout");
|
||||
const allUsers = await users.get();
|
||||
const hostUser = allUsers.find((mate) => mate.name === firstHost);
|
||||
if (!hostUser) throw new Error("Host not found");
|
||||
|
||||
await hostUser.apiLogin();
|
||||
|
||||
// Cancel the booking
|
||||
await page.goto(`/booking/${bookingUid}`);
|
||||
await page.getByTestId("cancel").click();
|
||||
await page.getByTestId("confirm_cancel").click();
|
||||
await page.waitForResponse((response) => response.url().includes("/api/cancel"));
|
||||
|
||||
// Logout and go back to booking page
|
||||
await page.goto("/auth/logout");
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
// Rebook with the same details
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// Verify a new host is assigned
|
||||
const newHost = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(newHost).not.toBeNull();
|
||||
expect(newHost).toBe(firstHost);
|
||||
|
||||
// Verify the booking was successful by checking the new booking UID
|
||||
const newBookingUid = page.url().split("/booking/")[1];
|
||||
expect(newBookingUid).not.toBeNull();
|
||||
expect(newBookingUid).not.toBe(bookingUid);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("User Event", () => {
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"your_full_name": "Your full name",
|
||||
"no_name": "No name",
|
||||
"enter_number_between_range": "Please enter a number between 1 and {{maxOccurences}}",
|
||||
"continue_as_guest_description": "Quick and easy - no account required. Perfect for one-time meetings.",
|
||||
"email_address": "Email address",
|
||||
"enter_valid_email": "Please enter a valid email",
|
||||
"please_schedule_future_call": " Please schedule a future call if we're not available in {{seconds}} seconds",
|
||||
@@ -629,6 +630,7 @@
|
||||
"in_person_attendee_address": "In Person (Attendee Address)",
|
||||
"somewhere_else": "Somewhere Else",
|
||||
"custom_attendee_location": "Custom attendee location",
|
||||
"continue_as_guest": "Continue as Guest",
|
||||
"any_location": "Any location",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
@@ -1297,6 +1299,11 @@
|
||||
"event_cancelled_trigger": "when event is canceled",
|
||||
"after_hosts_cal_video_no_show": "After hosts didn't join cal video",
|
||||
"after_guests_cal_video_no_show": "After guests didn't join cal video",
|
||||
"join_video_call": "Join the video call",
|
||||
"sign_in_to_cal_com": "Sign in to your Cal.com account",
|
||||
"log_in_to_cal_com": "Log in to Cal.com",
|
||||
"track_your_meetings": "Track your meetings, get notifications, and manage your schedule more effectively.",
|
||||
"choose_how_you_d_like_to_join_call": "Choose how you'd like to join this call",
|
||||
"new_event_trigger": "when new event is booked",
|
||||
"email_host_action": "send email to host",
|
||||
"email_attendee_action": "send email to attendees",
|
||||
@@ -1566,6 +1573,7 @@
|
||||
"workflow_turned_on_successfully": "{{workflowName}} workflow turned {{offOn}} successfully",
|
||||
"download_responses": "Download responses",
|
||||
"download_responses_description": "Download all responses to your form in CSV format.",
|
||||
"view_responses": "View Responses",
|
||||
"download": "Download",
|
||||
"downloading": "Downloading",
|
||||
"download_recording": "Download Recording",
|
||||
@@ -2812,6 +2820,7 @@
|
||||
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
|
||||
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
|
||||
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
|
||||
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
|
||||
"mass_assign_attributes": "Mass assign attributes",
|
||||
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
|
||||
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
|
||||
@@ -2908,6 +2917,7 @@
|
||||
"routing_form_insights_assignment_reason": "Assignment Reason",
|
||||
"access_denied": "Access Denied",
|
||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||
"salesforce_add_attendees_as":"Add new attendees as",
|
||||
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
||||
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||
"salesforce_option": "Salesforce Option",
|
||||
@@ -3131,7 +3141,6 @@
|
||||
"user_team": "User/Team",
|
||||
"client_id_copied": "Client ID copied!",
|
||||
"client_secret_copied": "Client secret copied!",
|
||||
"down_for_maintenance": "Down for maintenance",
|
||||
"maintenance_message": "The Cal.com team are performing scheduled maintenance. If you have any questions, please contact support.",
|
||||
"your_payment_failed": "Your payment failed",
|
||||
"payment_successful": "Payment successful!",
|
||||
@@ -3180,6 +3189,7 @@
|
||||
"routing_form_next_in_queue": "{{count}} next in queue",
|
||||
"routing_form_select_members_to_email": "Send email responses to",
|
||||
"routing_incomplete_booking_tab": "Incomplete Bookings",
|
||||
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
|
||||
"matching": "Matching",
|
||||
"event_redirect": "Event Redirect",
|
||||
"reset_form": "Reset Form",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const glob = require("glob");
|
||||
|
||||
const copyAppStoreStatic = () => {
|
||||
// Get all static files from app-store packages
|
||||
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
|
||||
|
||||
staticFiles.forEach((file) => {
|
||||
// Extract app name from path
|
||||
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
|
||||
if (!appNameMatch) return;
|
||||
|
||||
const appName = appNameMatch[1];
|
||||
const fileName = path.basename(file);
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
const destDir = path.join(process.cwd(), "public", "app-store", appName);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy file to destination
|
||||
const destPath = path.join(destDir, fileName);
|
||||
fs.copyFileSync(file, destPath);
|
||||
console.log(`Copied ${file} to ${destPath}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Run the copy function
|
||||
copyAppStoreStatic();
|
||||
@@ -0,0 +1,43 @@
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
const CLIENT_FILES_PATH = ".next/static/chunks";
|
||||
|
||||
try {
|
||||
// Continue if required any env vars are not set
|
||||
const requiredEnvVars = ["SENTRY_AUTH_TOKEN", "SENTRY_ORG", "SENTRY_PROJECT"];
|
||||
const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]);
|
||||
if (missingEnvVars.length > 0) {
|
||||
console.log(
|
||||
`Skipping release creation as required environment variables are not set: ${missingEnvVars.join(", ")}`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const release = execSync("git rev-parse HEAD").toString().trim();
|
||||
|
||||
// Add release
|
||||
execSync(`sentry-cli releases new ${release}`, { stdio: "inherit" });
|
||||
|
||||
// Add git commits
|
||||
execSync(`sentry-cli releases set-commits ${release} --auto`, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Inject Debug IDs
|
||||
execSync(`sentry-cli sourcemaps inject ${CLIENT_FILES_PATH}`, { stdio: "inherit" });
|
||||
|
||||
// Upload with release flag
|
||||
execSync(
|
||||
`sentry-cli sourcemaps upload ${CLIENT_FILES_PATH} --validate --ext=js --ext=map --release=${release}`,
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
|
||||
// Finalize the release
|
||||
execSync(`sentry-cli releases finalize ${release}`, { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
console.error("Sentry cli execution failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -2,4 +2,11 @@ import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
beforeSend(event) {
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "edge",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,12 @@ Sentry.init({
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
_experiments: {
|
||||
maxSpans: parseInt(process.env.SENTRY_MAX_SPANS ?? "1000") || 1000,
|
||||
integrations: [Sentry.prismaIntegration()],
|
||||
beforeSend(event) {
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "server",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1542,7 +1542,7 @@ describe("getSchedule", () => {
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
schedulingType: "COLLECTIVE",
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
hosts: [
|
||||
{
|
||||
userId: 101,
|
||||
@@ -1746,6 +1746,668 @@ describe("getSchedule", () => {
|
||||
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that
|
||||
});
|
||||
|
||||
test("test that duration limit is working correctly if user is all day available", async () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
durationLimits: {
|
||||
PER_DAY: 120, // 2 hours per day
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
durationLimits: {
|
||||
PER_DAY: 240, // 4 hours per day
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "All Day available",
|
||||
availability: [
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T23:59:59.999Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
timeZone: Timezones["+6:00"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T08:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T09:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T10:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T11:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 2,
|
||||
startTime: `${plus2DateString}T12:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T13:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const thisUserAvailabilityDurationLimitReached = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const thisUserAvailabilityDurationLimitNotReached = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 2,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
let availableSlotsInTz: dayjs.Dayjs[] = [];
|
||||
for (const date in thisUserAvailabilityDurationLimitReached.slots) {
|
||||
thisUserAvailabilityDurationLimitReached.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
|
||||
|
||||
availableSlotsInTz = [];
|
||||
for (const date in thisUserAvailabilityDurationLimitNotReached.slots) {
|
||||
thisUserAvailabilityDurationLimitNotReached.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("test that duration limit is working correctly with different timezone", async () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
durationLimits: {
|
||||
PER_DAY: 120, // 2 hours per day
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "All Day available",
|
||||
availability: [
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T23:59:59.999Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
timeZone: Timezones["+6:00"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T08:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T09:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T10:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T11:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const thisUserAvailabilityDurationLimit = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["-11:00"], // attendee timezone
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const availableSlotsInTz: dayjs.Dayjs[] = [];
|
||||
for (const date in thisUserAvailabilityDurationLimit.slots) {
|
||||
thisUserAvailabilityDurationLimit.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
|
||||
});
|
||||
|
||||
test.skip("test that PER_WEEK duration limits work correctly", async () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
const { dateString: plus4DateString } = getDate({ dateIncrement: 4 });
|
||||
const { dateString: plus5DateString } = getDate({ dateIncrement: 5 });
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
durationLimits: {
|
||||
PER_WEEK: 180, // 3 hours per week
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "All Day available",
|
||||
availability: [
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T23:59:59.999Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
timeZone: Timezones["+6:00"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus1DateString}T08:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T09:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T10:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T11:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus3DateString}T12:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T13:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const weeklyAvailability = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus5DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const availableSlotsInTz: dayjs.Dayjs[] = [];
|
||||
for (const date in weeklyAvailability.slots) {
|
||||
weeklyAvailability.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
availableSlotsInTz.filter(
|
||||
(slot) =>
|
||||
slot.format().startsWith(plus1DateString) ||
|
||||
slot.format().startsWith(plus2DateString) ||
|
||||
slot.format().startsWith(plus3DateString) ||
|
||||
slot.format().startsWith(plus4DateString)
|
||||
).length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test.skip("global team duration limit blocks slots if one fixed host reached limit", async () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
team: {
|
||||
id: 1,
|
||||
durationLimits: { PER_DAY: 120 }, // 2 hours per day
|
||||
},
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
team: {
|
||||
id: 1,
|
||||
durationLimits: { PER_DAY: 120 }, // 2 hours per day
|
||||
},
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "All Day available",
|
||||
availability: [
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T23:59:59.999Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
timeZone: Timezones["+6:00"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T08:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T09:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T10:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T11:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const availabilityEventTypeOne = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const availableSlotsInTz: dayjs.Dayjs[] = [];
|
||||
for (const date in availabilityEventTypeOne.slots) {
|
||||
availabilityEventTypeOne.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
|
||||
|
||||
const availabilityEventTypeTwo = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 2,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const availableSlotsInTz2: dayjs.Dayjs[] = [];
|
||||
for (const date in availabilityEventTypeTwo.slots) {
|
||||
availabilityEventTypeTwo.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz2.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(availableSlotsInTz2.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
|
||||
|
||||
const availabilityUserEventType = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 3,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const availableSlotsInTz3: dayjs.Dayjs[] = [];
|
||||
for (const date in availabilityUserEventType.slots) {
|
||||
availabilityUserEventType.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz3.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
availableSlotsInTz3.filter((slot) => slot.format().startsWith(plus2DateString)).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.skip("test that combined booking and duration limits work correctly", async () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
bookingLimits: {
|
||||
PER_DAY: 3, // 3 bookings per day
|
||||
},
|
||||
durationLimits: {
|
||||
PER_DAY: 120, // 2 hours per day
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
length: 30, // 30 minute event
|
||||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
bookingLimits: {
|
||||
PER_DAY: 2, // 2 bookings per day
|
||||
},
|
||||
durationLimits: {
|
||||
PER_DAY: 120, // 2 hours per day
|
||||
},
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "All Day available",
|
||||
availability: [
|
||||
{
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T00:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T23:59:59.999Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
timeZone: Timezones["+6:00"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T08:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T09:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 1,
|
||||
startTime: `${plus2DateString}T10:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T11:00:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 2,
|
||||
startTime: `${plus2DateString}T12:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T12:30:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const eventType1Availability = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
const eventType2Availability = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 2,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
let availableSlotsInTz: dayjs.Dayjs[] = [];
|
||||
for (const date in eventType1Availability.slots) {
|
||||
eventType1Availability.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
|
||||
|
||||
availableSlotsInTz = [];
|
||||
for (const date in eventType2Availability.slots) {
|
||||
eventType2Availability.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// Create a new booking scenario with an additional booking
|
||||
await createBookingScenario({
|
||||
eventTypes: scenarioData.eventTypes,
|
||||
users: scenarioData.users,
|
||||
bookings: [
|
||||
...scenarioData.bookings,
|
||||
{
|
||||
userId: 101,
|
||||
eventTypeId: 2,
|
||||
startTime: `${plus2DateString}T14:00:00.000Z`,
|
||||
endTime: `${plus2DateString}T14:30:00.000Z`,
|
||||
status: "ACCEPTED" as BookingStatus,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventType2AvailabilityUpdated = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 2,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${plus1DateString}T00:00:00.000Z`,
|
||||
endTime: `${plus3DateString}T23:59:59.999Z`,
|
||||
timeZone: Timezones["+6:00"],
|
||||
isTeamEvent: false,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
availableSlotsInTz = [];
|
||||
for (const date in eventType2AvailabilityUpdated.slots) {
|
||||
eventType2AvailabilityUpdated.slots[date].forEach((timeObj) => {
|
||||
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
|
||||
});
|
||||
}
|
||||
|
||||
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
|
||||
});
|
||||
|
||||
test("test that booking limit is working correctly if user is all day available and attendee is in different timezone than host", async () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
@@ -2246,7 +2908,7 @@ describe("getSchedule", () => {
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
schedulingType: "COLLECTIVE",
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
|
||||
@@ -114,6 +114,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -137,7 +139,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
users: { connect: [{ id: 4 }] },
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
instantMeetingScheduleId: undefined,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
@@ -170,6 +171,9 @@ describe("handleChildrenEventTypes", () => {
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
assignRRMembersUsingSegment,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -191,7 +195,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...rest,
|
||||
assignRRMembersUsingSegment: undefined,
|
||||
useEventLevelSelectedCalendars: undefined,
|
||||
customReplyToEmail: null,
|
||||
rrSegmentQueryValue: undefined,
|
||||
@@ -275,6 +278,9 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -307,7 +313,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
requiresBookerEmailVerification: false,
|
||||
userId: 4,
|
||||
workflows: undefined,
|
||||
hashedLink: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
},
|
||||
@@ -332,6 +337,11 @@ describe("handleChildrenEventTypes", () => {
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
rrSegmentQueryValue,
|
||||
useEventLevelSelectedCalendars,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -354,9 +364,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...rest,
|
||||
assignRRMembersUsingSegment: undefined,
|
||||
useEventLevelSelectedCalendars: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
customReplyToEmail: null,
|
||||
locations: [],
|
||||
hashedLink: {
|
||||
@@ -364,7 +371,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
instantMeetingScheduleId: undefined,
|
||||
},
|
||||
where: {
|
||||
userId_parentId: {
|
||||
@@ -396,6 +402,9 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -418,10 +427,14 @@ describe("handleChildrenEventTypes", () => {
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
requiresBookerEmailVerification: false,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
lockedTimeZone: "Europe/London",
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
workflows: [],
|
||||
parentId: 1,
|
||||
locations: [],
|
||||
instantMeetingScheduleId: null,
|
||||
assignRRMembersUsingSegment: false,
|
||||
includeNoShowInRRCalculation: false,
|
||||
...evType,
|
||||
};
|
||||
|
||||
@@ -449,7 +462,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
recurringEvent: undefined,
|
||||
eventTypeColor: undefined,
|
||||
customReplyToEmail: null,
|
||||
instantMeetingScheduleId: undefined,
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
@@ -461,28 +473,24 @@ describe("handleChildrenEventTypes", () => {
|
||||
workflows: {
|
||||
create: [{ workflowId: 11 }],
|
||||
},
|
||||
hashedLink: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
},
|
||||
});
|
||||
const { profileId, ...rest } = evType;
|
||||
const { profileId, rrSegmentQueryValue, ...rest } = evType;
|
||||
if ("workflows" in rest) delete rest.workflows;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...rest,
|
||||
locations: [],
|
||||
assignRRMembersUsingSegment: undefined,
|
||||
useEventLevelSelectedCalendars: undefined,
|
||||
customReplyToEmail: null,
|
||||
rrSegmentQueryValue: undefined,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
instantMeetingScheduleId: undefined,
|
||||
},
|
||||
where: {
|
||||
userId_parentId: {
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
{
|
||||
"path": "/api/tasks/cleanup",
|
||||
"schedule": "0 0 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/credentials",
|
||||
"schedule": "*/5 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/selected-calendars",
|
||||
"schedule": "*/5 * * * *"
|
||||
}
|
||||
],
|
||||
"functions": {
|
||||
|
||||
@@ -3145,6 +3145,80 @@
|
||||
"tags": ["Orgs / Teams / Bookings"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
|
||||
"summary": "Get 'Booking References' for a booking",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-secret-key",
|
||||
"in": "header",
|
||||
"description": "For platform customers - OAuth client secret key",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-client-id",
|
||||
"in": "header",
|
||||
"description": "For platform customers - OAuth client ID",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter booking references by type",
|
||||
"example": "google_calendar",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google_calendar",
|
||||
"office365_calendar",
|
||||
"daily_video",
|
||||
"google_video",
|
||||
"office365_video",
|
||||
"zoom_video"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Orgs / Teams / Bookings"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect": {
|
||||
"post": {
|
||||
"operationId": "OrganizationsConferencingController_connectTeamApp",
|
||||
@@ -7118,6 +7192,72 @@
|
||||
"tags": ["Bookings"]
|
||||
}
|
||||
},
|
||||
"/v2/bookings/{bookingUid}/references": {
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getBookingReferences",
|
||||
"summary": "Get 'Booking References' for a booking",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
"in": "header",
|
||||
"description": "Must be set to 2024-08-13",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "2024-08-13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter booking references by type",
|
||||
"example": "google_calendar",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google_calendar",
|
||||
"office365_calendar",
|
||||
"daily_video",
|
||||
"google_video",
|
||||
"office365_video",
|
||||
"zoom_video"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Bookings"]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
@@ -9110,7 +9250,7 @@
|
||||
"tags": ["Organization Team Verified Resources"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers": {
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones": {
|
||||
"get": {
|
||||
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
|
||||
"summary": "Get list of verified phone numbers of an org team.",
|
||||
@@ -9214,7 +9354,7 @@
|
||||
"tags": ["Organization Team Verified Resources"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers/{id}": {
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/{id}": {
|
||||
"get": {
|
||||
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneById",
|
||||
"summary": "Get verified phone number of an org team by id.",
|
||||
@@ -10976,7 +11116,7 @@
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "TeamsMembershipsController_updateTeamMembership",
|
||||
"summary": "Create a membership",
|
||||
"summary": "Update membership",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -11311,7 +11451,7 @@
|
||||
"tags": ["Teams Verified Resources"]
|
||||
}
|
||||
},
|
||||
"/v2/teams/{teamId}/verified-resources/phone-numbers": {
|
||||
"/v2/teams/{teamId}/verified-resources/phones": {
|
||||
"get": {
|
||||
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
|
||||
"summary": "Get list of verified phone numbers of a team.",
|
||||
@@ -11415,7 +11555,7 @@
|
||||
"tags": ["Teams Verified Resources"]
|
||||
}
|
||||
},
|
||||
"/v2/teams/{teamId}/verified-resources/phone-numbers/{id}": {
|
||||
"/v2/teams/{teamId}/verified-resources/phones/{id}": {
|
||||
"get": {
|
||||
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneById",
|
||||
"summary": "Get verified phone number of a team by id.",
|
||||
@@ -11675,7 +11815,7 @@
|
||||
"tags": ["Verified Resources"]
|
||||
}
|
||||
},
|
||||
"/v2/verified-resources/phone-numbers": {
|
||||
"/v2/verified-resources/phones": {
|
||||
"get": {
|
||||
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneNumbers",
|
||||
"summary": "Get list of verified phone numbers.",
|
||||
@@ -11763,7 +11903,7 @@
|
||||
"tags": ["Verified Resources"]
|
||||
}
|
||||
},
|
||||
"/v2/verified-resources/phone-numbers/{id}": {
|
||||
"/v2/verified-resources/phones/{id}": {
|
||||
"get": {
|
||||
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneById",
|
||||
"summary": "Get verified phone number by id.",
|
||||
@@ -14059,8 +14199,7 @@
|
||||
"eightxeight-video",
|
||||
"discord-video",
|
||||
"demodesk-video",
|
||||
"campfire-video",
|
||||
"around-video"
|
||||
"campfire-video"
|
||||
],
|
||||
"example": "cal-video"
|
||||
},
|
||||
@@ -16823,6 +16962,114 @@
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"BaseAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name"]
|
||||
},
|
||||
"TextAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "type", "value", "valueId"]
|
||||
},
|
||||
"NumberAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
},
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "type", "value", "valueId"]
|
||||
},
|
||||
"SingleSelectAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "type", "value", "valueId"]
|
||||
},
|
||||
"MultiSelectAttributeValue": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"valueId": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["valueId", "value"]
|
||||
},
|
||||
"MultiSelectAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MultiSelectAttributeValue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "type", "values"]
|
||||
},
|
||||
"MembershipUserOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16850,7 +17097,7 @@
|
||||
},
|
||||
"required": ["email"]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"OrganizationMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -16874,9 +17121,28 @@
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/MembershipUserOutputDto"
|
||||
},
|
||||
"attributes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TextAttribute"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NumberAttribute"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SingleSelectAttribute"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/MultiSelectAttribute"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["id", "userId", "teamId", "accepted", "role", "user"]
|
||||
"required": ["id", "userId", "teamId", "accepted", "role", "user", "attributes"]
|
||||
},
|
||||
"GetAllOrgMemberships": {
|
||||
"type": "object",
|
||||
@@ -16887,7 +17153,7 @@
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
@@ -16924,7 +17190,7 @@
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
@@ -16938,7 +17204,7 @@
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
@@ -16952,7 +17218,7 @@
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
@@ -16981,7 +17247,7 @@
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
@@ -18110,6 +18376,34 @@
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "number"
|
||||
},
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["MEMBER", "OWNER", "ADMIN"]
|
||||
},
|
||||
"disableImpersonation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/MembershipUserOutputDto"
|
||||
}
|
||||
},
|
||||
"required": ["id", "userId", "teamId", "accepted", "role", "user"]
|
||||
},
|
||||
"OrgTeamMembershipsOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19837,7 +20131,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
"type": "number"
|
||||
},
|
||||
"formId": {
|
||||
"type": "string"
|
||||
@@ -20315,8 +20609,7 @@
|
||||
"eightxeight-video",
|
||||
"discord-video",
|
||||
"demodesk-video",
|
||||
"campfire-video",
|
||||
"around-video"
|
||||
"campfire-video"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -22128,6 +22421,47 @@
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"BookingReference": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "The type of the booking reference"
|
||||
},
|
||||
"eventUid": {
|
||||
"type": "string",
|
||||
"description": "The event uid of the booking"
|
||||
},
|
||||
"destinationCalendarId": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The id of the calendar the event is created in"
|
||||
},
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The id of the booking reference"
|
||||
}
|
||||
},
|
||||
"required": ["type", "eventUid", "destinationCalendarId", "id"]
|
||||
},
|
||||
"BookingReferencesOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"description": "The status of the request, always 'success' for successful responses",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Booking References",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BookingReference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"CreateTeamMembershipInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
## Using `handleSlotReservation` for Custom Slot Selection Flows
|
||||
|
||||
If you want to trigger a custom action immediately when a user selects a timeslot (before the booking form appears), you can use the `handleSlotReservation` prop. This prop allows you to intercept the slot selection event, enabling you to run custom logic—such as calling the reserve slot api's https://cal.com/docs/api-reference/v2/slots/reserve-a-slot.
|
||||
|
||||
**Example usage:**
|
||||
|
||||
```tsx
|
||||
<Booker
|
||||
handleSlotReservation={(timeslot) => {
|
||||
// Your custom logic here
|
||||
console.log("User selected timeslot:", timeslot);
|
||||
// For example, call the reserve slot api's etc.
|
||||
}}
|
||||
// ...other props
|
||||
/>
|
||||
```
|
||||
|
||||
- When a user selects a slot, the `handleSlotReservation` function will be called with the selected timeslot as its argument.
|
||||
- If you provide this prop, the default slot reservation flow will be interrupted until your custom logic is complete.
|
||||
- This is useful for introducing custom slot reservation flows.
|
||||
Binary file not shown.
@@ -5,12 +5,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
|
||||
import type { CredentialOwner } from "../types";
|
||||
import OmniInstallAppButton from "./OmniInstallAppButton";
|
||||
@@ -49,45 +48,36 @@ export default function AppCard({
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle",
|
||||
app?.isInstalled && "mb-4 rounded-md border",
|
||||
!app.enabled && "grayscale"
|
||||
)}>
|
||||
<div className={classNames(app.isInstalled ? "p-4 text-sm sm:p-4" : "px-5 py-4 text-sm sm:px-5")}>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
|
||||
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
|
||||
<Link
|
||||
href={`/apps/${app.slug}`}
|
||||
className={classNames(app?.isInstalled ? "mr-[11px]" : "mr-3", "h-auto w-10 rounded-sm")}>
|
||||
<img
|
||||
className={classNames(
|
||||
app?.logo.includes("-dark") && "dark:invert",
|
||||
app?.isInstalled ? "min-w-[42px]" : "min-w-[40px]",
|
||||
"w-full"
|
||||
)}
|
||||
src={app?.logo}
|
||||
alt={app?.name}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex flex-col pe-3">
|
||||
<div className="text-emphasis">
|
||||
<span className={classNames(app?.isInstalled && "text-base", "font-semibold leading-4")}>
|
||||
{app?.name}
|
||||
</span>
|
||||
<Section className={classNames(!app?.isInstalled && "rounded-xl")}>
|
||||
<Section.Header
|
||||
rawHeading={
|
||||
<div>
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<Section.Title>{app?.name}</Section.Title>
|
||||
{!app?.isInstalled && (
|
||||
<span className="bg-emphasis ml-1 rounded px-1 py-0.5 text-xs font-medium leading-3 tracking-[0.01em]">
|
||||
{app?.categories[0].charAt(0).toUpperCase() + app?.categories[0].slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p title={app?.description} className="text-default line-clamp-1 pt-1 text-sm font-normal">
|
||||
{description || app?.description}
|
||||
</p>
|
||||
<Section.Description>{app?.description}</Section.Description>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
{app.credentialOwner && !isPlatform && (
|
||||
}
|
||||
iconSlot={
|
||||
<Link href={`/apps/${app.slug}`} className="flex h-8 w-8 items-center justify-center">
|
||||
<img
|
||||
className={classNames(
|
||||
app?.logo.includes("-dark") && "dark:invert",
|
||||
"max-h-full max-w-full object-contain"
|
||||
)}
|
||||
src={app?.logo}
|
||||
alt={app?.name}
|
||||
/>
|
||||
</Link>
|
||||
}>
|
||||
<div>
|
||||
<div>
|
||||
{/* {app.credentialOwner && !isPlatform && (
|
||||
<div className="ml-auto">
|
||||
<Badge variant="gray">
|
||||
<div className="flex items-center">
|
||||
@@ -101,10 +91,11 @@ export default function AppCard({
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
{app?.isInstalled || app.credentialOwner ? (
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch
|
||||
size="sm"
|
||||
disabled={!app.enabled || managedDisabled || disableSwitch}
|
||||
onCheckedChange={(enabled) => {
|
||||
if (switchOnClick) {
|
||||
@@ -128,32 +119,31 @@ export default function AppCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hideAppCardOptions ? null : (
|
||||
<div ref={animationRef}>
|
||||
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
|
||||
|
||||
{app?.isInstalled && switchChecked ? (
|
||||
app.isSetupAlready === undefined || app.isSetupAlready ? (
|
||||
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
|
||||
{!hideSettingsIcon && !isPlatform && (
|
||||
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
|
||||
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
|
||||
</Section.Header>
|
||||
{hideAppCardOptions
|
||||
? null
|
||||
: app?.isInstalled &&
|
||||
switchChecked && (
|
||||
<div ref={animationRef}>
|
||||
{app.isSetupAlready === undefined || app.isSetupAlready ? (
|
||||
<div className="relative text-sm [&_input]:mb-0 [&_input]:leading-4">
|
||||
{!hideSettingsIcon && !isPlatform && (
|
||||
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
|
||||
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
|
||||
<p>{t("this_app_is_not_setup_already")}</p>
|
||||
<Link href={`/apps/${app.slug}/setup`}>
|
||||
<Button StartIcon="settings">{t("setup")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
|
||||
<p>{t("this_app_is_not_setup_already")}</p>
|
||||
<Link href={`/apps/${app.slug}/setup`}>
|
||||
<Button StartIcon="settings">{t("setup")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import alby_config_json from "./alby/config.json";
|
||||
import amie_config_json from "./amie/config.json";
|
||||
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
|
||||
import around_config_json from "./around/config.json";
|
||||
import attio_config_json from "./attio/config.json";
|
||||
import autocheckin_config_json from "./autocheckin/config.json";
|
||||
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
|
||||
@@ -112,7 +111,6 @@ export const appStoreMetadata = {
|
||||
alby: alby_config_json,
|
||||
amie: amie_config_json,
|
||||
applecalendar: applecalendar__metadata_ts,
|
||||
around: around_config_json,
|
||||
attio: attio_config_json,
|
||||
autocheckin: autocheckin_config_json,
|
||||
"baa-for-hipaa": baa_for_hipaa_config_json,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user