Compare commits

...
23 Commits
Author SHA1 Message Date
GitHub Actions 4a96be4175 chore: release v5.2.12
Post release / release (push) Canceled after 0s
2025-05-07 19:34:41 +00:00
594bb606b8 perf: Refactor bookings queries for API (#16407)
* perf: Refactor bookings queries for API

* fix syntax

* fixing vars

* Inverted the filter by attendees if

* fixed prisma filter issue

* Defaulting list of emails to empty array

* Only running 2 queries if we have data to filter by

* Reverted changes that have been separated out to other PR

* fix: ts build error

* adds tests + extracts logic for units

* fix integration tests

* fix tests expectes

* Fix test

* switching back

* attempt : fix tests

* test: add test for duplicate bookings (#21165)

* feat: Add tests for booking and duration limits (#21158)

* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* test: add test to ensure no duplicate bookings when merging query results

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: deduplicate bookings when merging results from multiple queries

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: ensure pagination works correctly when merging results from multiple queries (#21167)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 11:44:56 -07:00
Omar López 1c088f65c5 chore: Removes around app (#20650) 2025-05-07 14:41:46 -04:00
devin-ai-integration[bot]andkeith@cal.com 7d7a429780 feat: Add tests for booking and duration limits (#21158)
* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 13:06:10 -04:00
GiridharandTushar Bhatt d3228441aa fix:decline-booking-reason-missing (#21144)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-07 21:01:33 +05:30
Morgan 21d6bf1fca fix: api v2 routing form responses flaky tests (#21150) 2025-05-07 16:22:02 +03:00
a4134aa5a3 feat: add 'View Responses' menu item to Routing Form dropdown (#21114)
* feat: add 'View Responses' menu item to Routing Form dropdown

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use nuqs createSerializer for URL generation

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: move serializer to data-table lib and use proper type constants

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: type errors in serializers and FormActions

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use parseAsArrayOf with parseAsJson for proper nuqs serialization

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: update to use dataTableQueryParamsSerializer with proper nuqs pattern

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: extract parsers from DataTableProvider to be reused in serializers

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove deprecated activeFiltersSerializer and use empty array instead of null

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: only show View Responses menu when form id and teamId exist

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* remove teamId condition

* refactor: improve viewResponses URL generation with cleaner conditional

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* Update packages/features/data-table/lib/parsers.ts

* refactor: make constants in parsers.ts non-exported

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations to constants in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations and export DEFAULT_PAGE_SIZE in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type assertions to DEFAULT_ACTIVE_FILTERS and DEFAULT_SORTING

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-07 13:10:21 +00:00
Anik Dhabal Babu 61397e5184 fix: e2e flakes (#20630)
* fix

* Update managed-event-types.e2e.ts

* fix
2025-05-07 18:04:06 +05:30
Somay Chauhan 6143cd60e5 feat: Add booking references API endpoints (#21127)
* feat: Add booking references API endpoints

* refactor: Implement booking-reference service and repositories

* Add description and reorder properties in BookingReference schema

* Refactor booking references service to separate output service
2025-05-07 12:30:53 +00:00
Eunjae Lee ac821f6b13 chore: convert booking_denormalized e2e test to integration test (#21148) 2025-05-07 14:23:25 +02:00
sean-brydon 2bce38fd2a feat(v3): table designs (#21147) 2025-05-07 09:54:36 +00:00
Hariom BalharaandOmar López 390ee79134 Reduce payload size (#21093)
Co-authored-by: Omar López <zomars@me.com>
2025-05-07 03:16:07 +00:00
cfc9f0bfc3 refactor: Consolidate date parameter extraction and booking period check logic (#21142)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-06 16:33:22 -07:00
Morgan 58d5f2a17c fix: advanced text filters getBookings with kysely (#21143) 2025-05-06 21:08:14 +00:00
Anwar Sadath 37f271bd74 fix: delete organization user endpoint not working in api v2 (#21136) 2025-05-06 19:10:55 +00:00
devin-ai-integration[bot]andkeith@cal.com d770cfc65e perf: Optimize team bookings query by fetching data for multiple users at once (#21137)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-06 11:47:06 -07:00
Eunjae Leeandsean-brydon ab8556790e feat: add denormalized table for BookingTimeStatus view (#20947)
* feat: add denormalized table for BookingTimeStatus

* Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* add more indexes

* improvement

* update schema

* improve indexes

* apply feedback from mrge

* split update trigger for better perf

* remove unnecessary checks

* split migration into two

* less logging

* address feedback

* add updatedAt and userUsername

* remove timeStatus

* add deletion trigger

* clean up denorm table on row deletion at EventType or users

* add timeStatus back as a part of a new view

* keep user and eventType nullable

* rename triggers

* add tests

* clean up migrations

* update tests

* update tests

* update test clean up

* one more try

* similar updates

* use e2e test instead

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-06 20:34:45 +02:00
Hariom Balhara 3d89b37cc6 Adding already released embed version (#21033) 2025-05-06 15:19:47 +00:00
GitHub Actions 78b213321c chore: release v5.2.11
Post release / release (push) Canceled after 0s
2025-05-06 14:43:34 +00:00
Carina WollendorferandCarinaWolli 276ba1179f fix: improvements for scheduling Email reminders (#21129)
* improvements cron job

* improvements emailReminderManager

* order bookings for reminders

* fix cleaning up past cancelled reminders

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-06 15:23:48 +01:00
Hariom BalharaandAnik Dhabal Babu 0109d166a1 Update delegation-credential.md (#21124)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-06 13:29:13 +00:00
Morgan 779c014256 fix: get bookings advanced email and attendee filter (#21125)
* fix: get bookings advanced email and attendee filter

* bump libraries

* bump libraries
2025-05-06 09:28:57 +01:00
sean-brydonandUdit Takkar 073faf9e10 feat: Salesforce Event Type App v3 designs (#21100)
* section component

* more section UI

* wip

* core components styled

* all components using sections

* match icons to figma

* small inputs

* fix select small to be the same as small text

* center flex rows

* enforce inputs to be full spanning grid

* tidy up section container for single inputs

* fix select and i18n

* wip

* install available apps

* raw heading

* app install card

* fix app logos width x height

* fix gap

* match design for other button

* fix prop name

* push i18n string

* fix doc snippets

* fix docs

* chore: remove yarn.lock changes

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-06 08:21:20 +00:00
118 changed files with 4283 additions and 809 deletions
+8
View File
@@ -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"
+8
View File
@@ -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;
}
+79 -57
View File
@@ -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"] },
},
},
},
],
});
});
});
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.194",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.196",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -0,0 +1,20 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BookingReferencesRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService) {}
async getBookingReferences(bookingId: number) {
return this.dbRead.prisma.bookingReference.findMany({
where: {
bookingId,
},
select: {
type: true,
uid: true,
id: 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";
@@ -58,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,
@@ -71,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,19 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
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 +86,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 +391,25 @@ 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
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getBookingReferences(bookingUid, userId);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -0,0 +1,39 @@
import { ApiProperty } 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 external uid of the booking reference",
})
externalUid!: string;
@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,41 @@
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 { 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) {
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);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
async getOrgBookingReferences(bookingUid: string) {
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);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
}
@@ -0,0 +1,18 @@
import { Injectable } from "@nestjs/common";
interface IBookingReference {
type: string;
uid: string;
id: number;
}
@Injectable()
export class OutputBookingReferencesService_2024_08_13 {
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
return bookingReferences.map((bookingReference) => ({
type: bookingReference.type,
externalUid: bookingReference.uid,
id: bookingReference.id,
}));
}
}
@@ -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({
@@ -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();
});
});
@@ -1,13 +1,17 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
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 +24,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 +37,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 +63,32 @@ 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
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -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 {}
@@ -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();
@@ -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: {
@@ -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,
+153 -4
View File
@@ -3282,6 +3282,64 @@
]
}
},
"/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"
}
}
],
"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 +7506,56 @@
]
}
},
"/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": "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",
@@ -11506,7 +11614,7 @@
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"summary": "Update membership",
"parameters": [
{
"name": "Authorization",
@@ -14956,7 +15064,6 @@
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
],
"example": "cal-video"
},
@@ -22190,7 +22297,6 @@
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
]
}
},
@@ -24144,6 +24250,49 @@
"data"
]
},
"BookingReference": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of the booking reference"
},
"externalUid": {
"type": "string",
"description": "The external uid of the booking reference"
},
"id": {
"type": "number",
"description": "The id of the booking reference"
}
},
"required": [
"type",
"externalUid",
"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": {
@@ -25019,4 +25168,4 @@
}
}
}
}
}
@@ -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 },
});
}
}
+1 -1
View File
@@ -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>
);
};
`;
+9
View File
@@ -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
@@ -26,21 +26,21 @@ 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)
Step 3: 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.
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>
- 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 following API scope for Google Calendar(Full access to Google Calendar)
- `https://www.googleapis.com/auth/calendar`
## Onboarding Improvements
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.10",
"version": "5.2.12",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -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();
+5
View File
@@ -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
+2 -5
View File
@@ -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 () => {
@@ -1566,6 +1566,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 +2813,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 +2910,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",
+664 -2
View File
@@ -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: [
{
+143 -5
View File
@@ -3145,6 +3145,62 @@
"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"
}
}
],
"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 +7174,54 @@
"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": "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",
@@ -10976,7 +11080,7 @@
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"summary": "Update membership",
"parameters": [
{
"name": "Authorization",
@@ -14059,8 +14163,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
],
"example": "cal-video"
},
@@ -20315,8 +20418,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
]
}
},
@@ -22128,6 +22230,42 @@
},
"required": ["status", "data"]
},
"BookingReference": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of the booking reference"
},
"externalUid": {
"type": "string",
"description": "The external uid of the booking reference"
},
"id": {
"type": "number",
"description": "The id of the booking reference"
}
},
"required": ["type", "externalUid", "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": {
+54 -64
View File
@@ -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,
@@ -6,7 +6,6 @@ export const apiHandlers = {
alby: import("./alby/api"),
amie: import("./amie/api"),
applecalendar: import("./applecalendar/api"),
around: import("./around/api"),
attio: import("./attio/api"),
autocheckin: import("./autocheckin/api"),
"baa-for-hipaa": import("./baa-for-hipaa/api"),
-13
View File
@@ -1,13 +0,0 @@
---
items:
- 1.jpg
- 2.jpg
- 3.jpg
- 4.jpg
- 5.jpg
- 6.jpg
- 7.jpg
- 8.jpg
---
Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together.
-30
View File
@@ -1,30 +0,0 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
export async function getHandler(req: NextApiRequest) {
const session = checkSession(req);
const slug = appConfig.slug;
const variant = appConfig.variant;
const appType = appConfig.type;
const teamId = req.query.teamId ? Number(req.query.teamId) : undefined;
const returnTo = req.query?.returnTo;
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
user: session.user,
slug,
key: {},
teamId,
});
return { url: returnTo ?? getInstalledAppPath({ variant, slug }) };
}
export default defaultResponder(getHandler);
-5
View File
@@ -1,5 +0,0 @@
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
export default defaultHandler({
GET: import("./_getAdd"),
});
-1
View File
@@ -1 +0,0 @@
export { default as add } from "./add";
-25
View File
@@ -1,25 +0,0 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Around",
"title": "Around",
"slug": "around",
"type": "around_video",
"logo": "icon.svg",
"url": "https://cal.com/",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together.",
"__createdUsingCli": true,
"appData": {
"location": {
"linkType": "static",
"type": "integrations:around_video",
"label": "Around Video",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?around.co\\/[a-zA-Z0-9]*",
"organizerInputPlaceholder": "https://www.around.co/rick"
}
},
"isOAuth": false
}
-1
View File
@@ -1 +0,0 @@
export * as api from "./api";
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/around",
"version": "0.0.0",
"main": "./index.ts",
"description": "Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together.",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

-15
View File
@@ -1,15 +0,0 @@
<svg width="326" height="218" viewBox="0 0 326 218" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M254.422 100.655L251.422 95.6551L249.422 94.6551H248.422L247.422 96.6551L245.422 93.6551L249.422 88.6551H246.422V87.6551L251.422 85.6551C251.012 65.8501 275.185 49.8181 275.422 29.6551C281.938 27.4911 287.784 11.9091 289.422 5.6551L305.422 11.6551C285.484 -7.4233 249.601 -1.6615 232.89 18.6591C217.567 37.2901 217.397 65.6011 232.103 84.6551C237.927 92.2021 245.735 97.0381 254.422 100.655Z" fill="#BC45B0"/>
<path d="M30.4224 95.6551C33.1044 99.2261 37.2596 99.4561 41.4224 98.6551L45.4224 100.655V99.6551H43.4224V98.6551L61.4224 94.6551V93.6551H53.4224V92.6551H59.4224L58.4224 88.6551C65.0064 83.9361 70.3954 69.5191 70.4224 61.6551H73.4224C75.3534 53.9071 81.5524 42.0621 87.4224 36.6551L86.4224 33.6551L89.4224 35.6551H90.4224L86.4224 29.6551L88.4224 27.6551L86.4224 22.6551L88.4224 23.6551C61.5744 -11.6715 4.90777 3.88079 0.592173 48.6551C-0.979227 64.9571 10.6359 95.7931 30.4224 95.6551ZM154.422 3.65509L155.422 4.65509L154.422 3.65509Z" fill="#2D99EA"/>
<path d="M159.422 100.655C156.838 94.9811 157.606 91.4601 159.422 85.6551H160.422V100.655C165.409 99.0001 164.422 94.1951 164.422 89.6551C164.422 82.5361 160.982 62.5531 166.164 57.2581C169.725 53.6211 180.677 55.6551 185.422 55.6551V53.6551L178.422 52.6551C183.113 42.0121 189.805 32.3381 194.738 21.6551C196.932 16.9031 198.013 11.3471 203.422 9.65507V5.65507C210.27 7.73377 208.422 15.7791 208.422 21.6551C209.946 18.0551 211.985 8.41717 208.818 5.25767C205.545 1.99317 196.667 3.65507 192.422 3.65507C177.325 3.65507 159.88 1.25047 145.422 6.35417C114.318 17.3341 113.422 45.9141 113.422 73.6551C113.422 78.8941 110.673 96.1801 115.025 99.6271C117.807 101.83 124.094 100.655 127.422 100.655H159.422Z" fill="#5663D9"/>
<path d="M203.422 5.65509V9.65509C197.804 10.6211 196.887 15.9991 194.738 20.6551C189.836 31.2701 181.722 41.6131 178.422 52.6551C185.79 54.2491 201.255 59.9031 207.988 54.0521C211.089 51.3571 210.421 46.3221 210.422 42.6551V13.6551H209.422V21.6551C207.744 15.9791 210.865 6.89729 203.422 5.65509Z" fill="#BC45B0"/>
<path d="M289.422 5.65509C286.546 12.5581 282.218 25.9801 275.422 29.6551C275.061 41.0371 264.753 56.4611 258.968 66.0791C255.724 71.4721 250.414 79.2761 251.422 85.6551L246.422 88.6551H249.422V89.6551L245.422 93.6551L248.422 94.6551H249.422L251.422 95.6551L254.422 101.655L257.422 100.655V102.655L261.422 101.655C261.422 104.281 263.185 103.446 265.422 102.655C267.971 105.572 272.746 104.731 276.422 104.64C287.422 104.37 299.16 99.7151 307.422 92.4701C335.781 67.6031 330.738 12.8261 289.422 5.65509ZM297.422 5.65509L298.422 6.65509L297.422 5.65509ZM246.422 6.65509L247.422 7.65509L246.422 6.65509ZM299.422 6.65509L300.422 7.65509L299.422 6.65509ZM300.422 7.65509L301.422 8.65509L300.422 7.65509Z" fill="#F55857"/>
<path d="M86.4224 22.6551V29.6551L90.4224 35.6551H89.4224L86.4224 33.6551L87.4224 36.6551C81.3674 40.4521 74.4264 54.5951 73.4224 61.6551H70.4224C70.3284 70.0711 64.2364 82.5501 58.4224 88.6551L59.4224 92.6551H53.4224V93.6551H61.4224V94.6551L43.4224 98.6551V99.6551C55.6197 102.559 69.9164 100.655 82.4224 100.655C85.9534 100.655 92.8794 101.956 95.8194 99.6271C99.5734 96.6541 97.4224 84.0361 97.4224 79.6551C97.4224 61.9341 101.676 35.6351 86.4224 22.6551ZM84.7554 24.3221L85.0894 24.9881L84.7554 24.3221Z" fill="#5663D9"/>
<path d="M111.422 202.655H112.422C112.422 179.81 122.625 164.69 132.129 144.67C135.116 138.376 141.815 130.34 142.422 123.655L148.625 116.78L159.422 112.655C152.105 114.014 145.21 114.531 138.422 117.925C105.299 134.487 111.422 171.736 111.422 202.655Z" fill="#5663D9"/>
<path d="M159.422 112.655L147.777 116.815L142.422 123.655C140.044 136.739 127.321 150.579 121.866 162.651C114.209 179.595 112.411 193.837 111.422 212.655L141.422 213.655H202.422V212.655C198.838 212.655 191.089 214.207 190.422 209.655H185.422V208.655L189.422 207.655L205.422 173.655H206.422L207.422 198.655C210.123 192.219 208.422 182.606 208.422 175.655C208.422 151.554 204.299 127.728 179.422 116.993C172.952 114.201 166.253 113.833 159.422 112.655Z" fill="#BC45B0"/>
<path d="M0.422375 175.655H1.42238V166.655H2.42238V184.655H3.42238V182.655H4.42238V189.655H5.42238L4.42238 185.655L8.42238 190.655H9.42238L8.42238 181.655H13.4224C13.7901 176.059 16.7554 167.374 22.4224 165.655V157.655H25.4224C28.2315 146.85 35.2776 132.41 43.4224 124.655L41.4224 120.655L45.4224 119.655V118.655H33.4224V117.655H57.4224C46.2045 112.948 26.6283 116.655 14.4224 116.655C10.7318 116.655 3.85378 115.371 1.45018 118.973C-1.02942 122.689 0.422375 130.37 0.422375 134.655V175.655Z" fill="#2D99EA"/>
<path d="M33.4224 117.655V118.655H45.4224V119.655L41.4224 120.655L43.4224 124.655C34.9301 129.237 33.6138 140.337 29.7835 148.433C28.1052 151.98 25.7223 153.456 25.4224 157.655H22.4224V165.655C16.1837 166.695 13.5917 175.943 13.4224 181.655H8.42237L9.42237 190.655H8.42237L4.42237 185.655C12.612 220.117 58.4578 227.134 82.4184 205.481C98.2224 191.2 97.4224 172.14 97.4224 152.655V126.655C97.4214 123.753 98.2434 118.998 95.1044 117.397C90.5954 115.099 82.3794 116.655 77.4224 116.655C62.7084 116.655 48.1453 117.655 33.4224 117.655Z" fill="#5663D9"/>
<path d="M224.422 156.655H225.422L228.422 126.655C231.665 126.173 231.729 122.576 231.422 119.655L243.422 118.655C239.842 117.153 227.144 115.411 224.677 119.173C222.746 122.116 224.276 127.37 224.387 130.655C224.676 139.304 224.422 148.001 224.422 156.655Z" fill="#BC45B0"/>
<path d="M229.422 116.655V117.655H243.422V118.655L231.422 119.655C231.9 122.043 231.161 127.34 228.422 126.655L225.422 156.655H224.422V125.655C220.114 135.921 223.422 153.497 223.422 164.655V198.655C223.422 202.186 222.121 209.112 224.45 212.052C226.313 214.405 230.805 213.654 233.422 213.655H258.422C276.419 213.655 293.757 213.835 307.133 199.565C334.867 169.975 316.732 122.715 277.422 117.083C261.938 114.865 245.061 116.655 229.422 116.655ZM223.422 118.655L224.422 119.655L223.422 118.655Z" fill="#F55857"/>
<path d="M205.422 173.655L189.422 207.655L185.422 208.655V209.655H190.422C191.022 214.433 203.028 212.989 207.422 213.655C209.751 204.66 208.983 182.141 205.422 173.655Z" fill="#F55857"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

@@ -2,7 +2,6 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import around_config_json from "./around/config.json";
import campfire_config_json from "./campfire/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import demodesk_config_json from "./demodesk/config.json";
@@ -48,7 +47,6 @@ import whereby_config_json from "./whereby/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
around: around_config_json,
campfire: campfire_config_json,
dailyvideo: dailyvideo__metadata_ts,
demodesk: demodesk_config_json,
-1
View File
@@ -1,7 +1,6 @@
const appStore = {
alby: createCachedImport(() => import("./alby")),
applecalendar: createCachedImport(() => import("./applecalendar")),
aroundvideo: createCachedImport(() => import("./around")),
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
closecom: createCachedImport(() => import("./closecom")),
dailyvideo: createCachedImport(() => import("./dailyvideo")),
@@ -4,6 +4,8 @@ import { useForm } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { dataTableQueryParamsSerializer } from "@calcom/features/data-table/lib/serializers";
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { RoutingFormEmbedButton, RoutingFormEmbedDialog } from "@calcom/features/embed/RoutingFormEmbed";
import { EmbedDialogProvider } from "@calcom/features/embed/lib/hooks/useEmbedDialogCtx";
@@ -400,6 +402,7 @@ type FormActionType =
| "duplicate"
| "download"
| "copyRedirectUrl"
| "viewResponses"
| "create";
type FormActionProps<T> = {
@@ -484,6 +487,17 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
create: {
onClick: () => setNewFormDialogState?.({ action: "new", target: "" }),
},
viewResponses: {
href: `/insights/routing${
routingForm?.id
? dataTableQueryParamsSerializer({
activeFilters: [
{ f: "formId", v: { type: ColumnFilterType.SINGLE_SELECT, data: routingForm.id } },
],
})
: ""
}`,
},
copyRedirectUrl: {
onClick: () => {
navigator.clipboard.writeText(redirectUrl);
@@ -141,6 +141,18 @@ const Actions = ({
StartIcon="download">
{t("download_responses")}
</FormAction>
{form?.id && (
<FormAction
action="viewResponses"
routingForm={form}
className="w-full"
color="minimal"
type="button"
data-testid="view-responses"
StartIcon="eye">
{t("view_responses")}
</FormAction>
)}
<FormAction
action="embed"
routingForm={form}
@@ -10,9 +10,10 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { InputField } from "@calcom/ui/components/form";
import { InputField, Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
import { showToast } from "@calcom/ui/components/toast";
import { SalesforceRecordEnum } from "../lib/enums";
@@ -74,14 +75,21 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
// Used when creating events under leads or contacts under account
const CreateContactUnderAccount = () => {
return (
<Switch
label={t("salesforce_create_new_contact_under_account")}
labelOnLeading
checked={createNewContactUnderAccount}
onCheckedChange={(checked) => {
setAppData("createNewContactUnderAccount", checked);
}}
/>
<Section.SubSection>
<Section.SubSectionHeader
icon="at-sign"
title={t("salesforce_create_new_contact_under_account")}
labelFor="create-new-contact-under-account">
<Switch
size="sm"
labelOnLeading
checked={createNewContactUnderAccount}
onCheckedChange={(checked) => {
setAppData("createNewContactUnderAccount", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
);
};
@@ -95,107 +103,135 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
switchChecked={enabled}
hideSettingsIcon>
<>
<div className="mb-4 ml-2">
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
{t("salesforce_create_record_as")}
</label>
<Select
className="mt-2 w-60"
options={recordOptions}
value={createEventOnSelectedOption}
onChange={(e) => {
if (e) {
setCreateEventOnSelectedOption(e);
setAppData("createEventOn", e.value);
}
}}
/>
</div>
<div className="mb-4">
<Switch
label={t("salesforce_ignore_guests")}
labelOnLeading
checked={ignoreGuests}
onCheckedChange={(checked) => {
setAppData("ignoreGuests", checked);
}}
/>
</div>
<Section.Content>
<Section.SubSection>
<Section.SubSectionHeader
icon="zap"
title={t("salesforce_add_attendees_as")}
justify="start"
labelFor="add-attendees-as">
<Select
size="sm"
id="add-attendees-as"
className="w-[200px]"
options={recordOptions}
value={createEventOnSelectedOption}
onChange={(e) => {
if (e) {
setCreateEventOnSelectedOption(e);
setAppData("createEventOn", e.value);
}
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader
icon="user-plus"
title={t("salesforce_ignore_guests")}
labelFor="ignore-guests">
<Switch
size="sm"
labelOnLeading
checked={ignoreGuests}
onCheckedChange={(checked) => {
setAppData("ignoreGuests", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
{createEventOnSelectedOption.value === SalesforceRecordEnum.CONTACT ? (
<div>
<Switch
label={t("skip_contact_creation", { appName: "Salesforce" })}
labelOnLeading
checked={isSkipContactCreationEnabled}
onCheckedChange={(checked) => {
setAppData("skipContactCreation", checked);
}}
/>
</div>
<Section.SubSection>
<Section.SubSectionHeader
icon="user-plus"
title={t("skip_contact_creation", { appName: "Salesforce" })}
labelFor="skip-contact-creation">
<Switch
size="sm"
labelOnLeading
checked={isSkipContactCreationEnabled}
onCheckedChange={(checked) => {
setAppData("skipContactCreation", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
) : null}
{createEventOnSelectedOption.value === SalesforceRecordEnum.LEAD ? (
<div>
<Switch
label={t("salesforce_create_event_on_contact")}
labelOnLeading
checked={createEventOnLeadCheckForContact}
onCheckedChange={(checked) => {
setAppData("createEventOnLeadCheckForContact", checked);
}}
/>
<div className="mt-4">
<CreateContactUnderAccount />
</div>
</div>
<>
<Section.SubSection>
<Section.SubSectionHeader
icon="user-plus"
title={t("salesforce_create_event_on_contact")}
labelFor="create-event-on-contact">
<Switch size="sm" labelOnLeading checked={createEventOnLeadCheckForContact} />
</Section.SubSectionHeader>
</Section.SubSection>
<CreateContactUnderAccount />
</>
) : null}
{createEventOnSelectedOption.value === SalesforceRecordEnum.ACCOUNT ? (
<>
<div className="mb-4">
<CreateContactUnderAccount />
</div>
<div>
<Switch
label={t("salesforce_if_account_does_not_exist")}
labelOnLeading
checked={createLeadIfAccountNull}
onCheckedChange={(checked) => {
setAppData("createLeadIfAccountNull", checked);
}}
/>
</div>
<CreateContactUnderAccount />
<Section.SubSection>
<Section.SubSectionHeader
icon="user-plus"
title={t("salesforce_if_account_does_not_exist")}
labelFor="create-lead-if-account-null">
<Switch
size="sm"
labelOnLeading
checked={createLeadIfAccountNull}
onCheckedChange={(checked) => {
setAppData("createLeadIfAccountNull", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
</>
) : null}
<div className="mt-4">
<Switch
label={t("on_booking_write_to_event_object")}
labelOnLeading
checked={onBookingWriteToEventObject}
onCheckedChange={(checked) => {
setAppData("onBookingWriteToEventObject", checked);
}}
/>
<Section.SubSection>
<Section.SubSectionHeader icon="badge-check" title={t("on_booking_write_to_event_object")}>
<Switch
size="sm"
labelOnLeading
checked={onBookingWriteToEventObject}
onCheckedChange={(checked) => {
setAppData("onBookingWriteToEventObject", checked);
}}
/>
</Section.SubSectionHeader>
{onBookingWriteToEventObject ? (
<div className="ml-2 mt-2">
<div className="grid grid-cols-3 gap-4">
<div>{t("field_name")}</div>
<div>{t("value")}</div>
<Section.SubSectionContent>
<div className="text-subtle flex gap-3 px-3 py-[6px] text-sm font-medium">
<div className="flex-1">{t("field_name")}</div>
<div className="flex-1">{t("value")}</div>
<div className="w-10" />
</div>
<div>
<Section.SubSectionNested>
{Object.keys(onBookingWriteToEventObjectMap).map((key) => (
<div className="mt-2 grid grid-cols-3 gap-4" key={key}>
<div>
<InputField value={key} readOnly />
<div className="flex items-center gap-2" key={key}>
<div className="flex-1">
<InputField value={key} readOnly size="sm" className="w-full" />
</div>
<div>
<InputField value={onBookingWriteToEventObjectMap[key]} readOnly />
<div className="flex-1">
<InputField
value={onBookingWriteToEventObjectMap[key]}
readOnly
size="sm"
className="w-full"
/>
</div>
<div>
<div className="flex w-10 justify-center">
<Button
StartIcon="trash"
StartIcon="x"
variant="icon"
color="destructive"
size="sm"
color="minimal"
onClick={() => {
const newObject = onBookingWriteToEventObjectMap;
delete onBookingWriteToEventObjectMap[key];
@@ -205,9 +241,11 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
</div>
</div>
))}
<div className="mt-2 grid grid-cols-3 gap-4">
<div>
<div className="mt-2 flex gap-4">
<div className="flex-1">
<InputField
size="sm"
className="w-full"
value={newOnBookingWriteToEventObjectField.field}
onChange={(e) =>
setNewOnBookingWriteToEventObjectField({
@@ -217,8 +255,10 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}
/>
</div>
<div>
<div className="flex-1">
<InputField
size="sm"
className="w-full"
value={newOnBookingWriteToEventObjectField.value}
onChange={(e) =>
setNewOnBookingWriteToEventObjectField({
@@ -228,11 +268,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}
/>
</div>
<div className="w-10" />
</div>
</div>
</Section.SubSectionNested>
<Button
className="mt-2"
className="text-subtle mt-2 w-fit"
size="sm"
color="secondary"
disabled={
!(newOnBookingWriteToEventObjectField.field && newOnBookingWriteToEventObjectField.value)
}
@@ -255,11 +297,11 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}>
{t("add_new_field")}
</Button>
</div>
</Section.SubSectionContent>
) : null}
</div>
</Section.SubSection>
<div className="mt-4">
<Section.SubSection>
<WriteToObjectSettings
bookingAction={BookingActionEnum.ON_BOOKING}
optionLabel={t("salesforce_on_booking_write_to_record", { record: createEventOn })}
@@ -270,89 +312,128 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
updateWriteToObjectData={(data) => setAppData("onBookingWriteToRecordFields", data)}
/>
</div>
</Section.SubSection>
<div className="mt-4">
<Switch
label="Change record owner on booking"
labelOnLeading
checked={onBookingChangeRecordOwner}
onCheckedChange={(checked) => {
setAppData("onBookingChangeRecordOwner", checked);
}}
/>
</div>
{onBookingChangeRecordOwner ? (
<div className="ml-2 mt-2">
<p className="mb-2">{t("salesforce_owner_name_to_change")}</p>
<InputField
value={onBookingChangeRecordOwnerName}
onChange={(e) => setAppData("onBookingChangeRecordOwnerName", e.target.value)}
/>
</div>
) : null}
{eventType.schedulingType === SchedulingType.ROUND_ROBIN ? (
<div className="mt-4">
<Section.SubSection>
<Section.SubSectionHeader
icon="user-plus"
title={t("salesforce_change_record_owner_on_booking")}
labelFor="change-record-owner-on-booking">
<Switch
label={t("salesforce_book_directly_with_attendee_owner")}
size="sm"
labelOnLeading
checked={isRoundRobinLeadSkipEnabled}
checked={onBookingChangeRecordOwner}
onCheckedChange={(checked) => {
setAppData("roundRobinLeadSkip", checked);
if (checked) {
// temporary solution, enabled should always be already set
setAppData("enabled", checked);
}
setAppData("onBookingChangeRecordOwner", checked);
}}
/>
{isRoundRobinLeadSkipEnabled ? (
<>
<div className="my-4 ml-2">
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
{t("salesforce_check_owner_of")}
</label>
<Select
className="mt-2 w-60"
options={checkOwnerOptions}
value={checkOwnerSelectedOption}
onChange={(e) => {
if (e) {
setCheckOwnerSelectedOption(e);
setAppData("roundRobinSkipCheckRecordOn", e.value);
}
}}
/>
</div>
{checkOwnerSelectedOption.value === SalesforceRecordEnum.CONTACT ? (
<div className="my-4">
<Switch
label={t("salesforce_round_robin_skip_fallback_to_lead_owner")}
labelOnLeading
checked={roundRobinSkipFallbackToLeadOwner}
onCheckedChange={(checked) => {
setAppData("roundRobinSkipFallbackToLeadOwner", checked);
</Section.SubSectionHeader>
{onBookingChangeRecordOwner ? (
<Section.SubSectionContent classNames={{ container: "p-3" }}>
<div>
<Label
htmlFor="on-booking-change-record-owner-name"
className="text-subtle text-sm font-medium">
{t("salesforce_owner_name_to_change")}
</Label>
<InputField
id="on-booking-change-record-owner-name"
size="sm"
value={onBookingChangeRecordOwnerName}
onChange={(e) => setAppData("onBookingChangeRecordOwnerName", e.target.value)}
/>
</div>
</Section.SubSectionContent>
) : null}
</Section.SubSection>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN ? (
<>
<Section.SubSection>
<Section.SubSectionHeader
icon="users"
title={t("salesforce_book_directly_with_attendee_owner")}
labelFor="book-directly-with-attendee-owner">
<Switch
size="sm"
id="book-directly-witha-attendee-owner"
checked={isRoundRobinLeadSkipEnabled}
onCheckedChange={(checked) => {
setAppData("roundRobinLeadSkip", checked);
if (checked) {
// temporary solution, enabled should always be already set
setAppData("enabled", checked);
}
}}
/>
</Section.SubSectionHeader>
{isRoundRobinLeadSkipEnabled ? (
<Section.SubSectionContent classNames={{ container: "p-3" }}>
<div>
<Label
htmlFor="round-robin-skip-check-record-on"
className="text-subtle text-sm font-medium">
{t("salesforce_check_owner_of")}
</Label>
<Select
size="sm"
className="w-60"
options={checkOwnerOptions}
value={checkOwnerSelectedOption}
onChange={(e) => {
if (e) {
setCheckOwnerSelectedOption(e);
setAppData("roundRobinSkipCheckRecordOn", e.value);
}
}}
/>
</div>
) : null}
<div className="my-4">
<Switch
label={t("salesforce_if_free_email_domain_skip_owner_check")}
labelOnLeading
checked={ifFreeEmailDomainSkipOwnerCheck}
onCheckedChange={(checked) => {
setAppData("ifFreeEmailDomainSkipOwnerCheck", checked);
}}
/>
</div>
</>
) : null}
<Alert className="mt-2" severity="neutral" title={t("skip_rr_description")} />
</div>
</Section.SubSectionContent>
) : null}
</Section.SubSection>
<>
{isRoundRobinLeadSkipEnabled ? (
<>
{checkOwnerSelectedOption.value === SalesforceRecordEnum.CONTACT ? (
<Section.SubSection>
<Section.SubSectionHeader
icon="users"
title={t("salesforce_round_robin_skip_fallback_to_lead_owner")}
labelFor="round-robin-skip-fallback-to-lead-owner">
<Switch
id="round-robin-skip-fallback-to-lead-owner"
size="sm"
checked={roundRobinSkipFallbackToLeadOwner}
onCheckedChange={(checked) => {
setAppData("roundRobinSkipFallbackToLeadOwner", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
) : null}
<Section.SubSection>
<Section.SubSectionHeader
icon="users"
title={t("salesforce_if_free_email_domain_skip_owner_check")}
labelFor="if-free-email-domain-skip-owner-check">
<Switch
id="if-free-email-domain-skip-owner-check"
size="sm"
checked={ifFreeEmailDomainSkipOwnerCheck}
onCheckedChange={(checked) => {
setAppData("ifFreeEmailDomainSkipOwnerCheck", checked);
}}
/>
</Section.SubSectionHeader>
<Alert severity="info" title={t("skip_rr_description")} />
</Section.SubSection>
</>
) : null}
</>
</>
) : null}
<div className="mt-4">
<Section.SubSection>
<WriteToObjectSettings
bookingAction={BookingActionEnum.ON_CANCEL}
optionLabel={t("salesforce_on_cancel_write_to_event")}
@@ -363,27 +444,39 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
updateWriteToObjectData={(data) => setAppData("onCancelWriteToEventRecordFields", data)}
/>
</div>
</Section.SubSection>
<div className="ml-2 mt-4">
<Switch
label="Send no show attendee data to event object"
checked={sendNoShowAttendeeData}
onCheckedChange={(checked) => {
setAppData("sendNoShowAttendeeData", checked);
}}
/>
<Section.SubSection>
<Section.SubSectionHeader
icon="calendar"
title="Send no show attendee data to event object"
labelFor="send-no-show-attendee-data">
<Switch
id="send-no-show-attendee-data"
size="sm"
checked={sendNoShowAttendeeData}
onCheckedChange={(checked) => {
setAppData("sendNoShowAttendeeData", checked);
}}
/>
</Section.SubSectionHeader>
{sendNoShowAttendeeData ? (
<div className="mt-2">
<p className="mb-2">Field name to check (must be checkbox data type)</p>
<Section.SubSectionContent classNames={{ container: "p-3" }}>
<Label
htmlFor="send-no-show-attendee-data-field-name"
className="text-subtle text-sm font-medium">
Field name to check (must be checkbox data type)
</Label>
<InputField
id="send-no-show-attendee-data-field-name"
size="sm"
value={sendNoShowAttendeeDataField}
onChange={(e) => setAppData("sendNoShowAttendeeDataField", e.target.value)}
/>
</div>
</Section.SubSectionContent>
) : null}
</div>
</>
</Section.SubSection>
</Section.Content>
</AppCard>
);
};
@@ -6,6 +6,7 @@ import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { InputField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
import { showToast } from "@calcom/ui/components/toast";
import { WhenToWriteToRecord, SalesforceFieldType, DateFieldTypeData } from "../../lib/enums";
@@ -81,37 +82,45 @@ const WriteToObjectSettings = ({
return (
<>
<Switch
label={optionLabel}
labelOnLeading
checked={optionEnabled}
onCheckedChange={optionSwitchOnChange}
/>
<Section.SubSectionHeader icon="star" labelFor="write-to-object-settings" title={optionLabel}>
<Switch
checked={optionEnabled}
onCheckedChange={optionSwitchOnChange}
id="write-to-object-settings"
size="sm"
/>
</Section.SubSectionHeader>
{optionEnabled ? (
<div className="ml-2 mt-2">
<div className="grid grid-cols-5 gap-4">
<div>{t("field_name")}</div>
<div>{t("field_type")}</div>
<div>{t("value")}</div>
<div>{t("when_to_write")}</div>
<Section.SubSectionContent>
<div className="text-subtle flex gap-3 px-3 py-[6px] text-sm font-medium">
<div className="flex-1">{t("field_name")}</div>
<div className="flex-1">{t("field_type")}</div>
<div className="flex-1">{t("value")}</div>
<div className="flex-1">{t("when_to_write")}</div>
<div className="w-10" />
</div>
<div>
<Section.SubSectionNested>
{Object.keys(writeToObjectData).map((key) => (
<div className="mt-2 grid grid-cols-5 gap-4" key={key}>
<div>
<InputField value={key} readOnly />
<div className="flex items-center gap-2" key={key}>
<div className="flex-1">
<InputField value={key} readOnly size="sm" className="w-full" />
</div>
<div>
<div className="flex-1">
<Select
size="sm"
className="w-full"
value={fieldTypeOptions.find(
(option) => option.value === writeToObjectData[key].fieldType
)}
isDisabled={true}
/>
</div>
<div>
<div className="flex-1">
{writeToObjectData[key].fieldType === SalesforceFieldType.DATE ? (
<Select
size="sm"
className="w-full"
value={dateFieldValueOptions.find(
(option) => option.value === writeToObjectData[key].value
)}
@@ -119,28 +128,38 @@ const WriteToObjectSettings = ({
/>
) : writeToObjectData[key].fieldType === SalesforceFieldType.CHECKBOX ? (
<Select
size="sm"
className="w-full"
value={checkboxFieldValueOptions.find(
(option) => option.value === writeToObjectData[key].value
)}
isDisabled={true}
/>
) : (
<InputField value={writeToObjectData[key].value as string} readOnly />
<InputField
value={writeToObjectData[key].value as string}
readOnly
size="sm"
className="w-full"
/>
)}
</div>
<div>
<div className="flex-1">
<Select
size="sm"
className="w-full"
value={whenToWriteToRecordOptions.find(
(option) => option.value === writeToObjectData[key].whenToWrite
)}
isDisabled={true}
/>
</div>
<div>
<div className="flex w-10 justify-center">
<Button
StartIcon="trash"
size="sm"
StartIcon="x"
variant="icon"
color="destructive"
color="minimal"
onClick={() => {
const newObject = writeToObjectData;
delete writeToObjectData[key];
@@ -150,9 +169,11 @@ const WriteToObjectSettings = ({
</div>
</div>
))}
<div className="mt-2 grid grid-cols-5 gap-4">
<div>
<div className="flex gap-2">
<div className="flex-1">
<InputField
size="sm"
className="w-full"
value={newOnWriteToRecordEntry.field}
onChange={(e) =>
setNewOnWriteToRecordEntry({
@@ -162,8 +183,10 @@ const WriteToObjectSettings = ({
}
/>
</div>
<div>
<div className="flex-1">
<Select
size="sm"
className="w-full"
options={fieldTypeOptions}
value={fieldTypeSelectedOption}
onChange={(e) => {
@@ -181,9 +204,11 @@ const WriteToObjectSettings = ({
}}
/>
</div>
<div>
<div className="flex-1">
{newOnWriteToRecordEntry.fieldType === SalesforceFieldType.DATE ? (
<Select
size="sm"
className="w-full"
options={dateFieldValueOptions}
value={dateFieldSelectedOption}
onChange={(e) => {
@@ -198,6 +223,8 @@ const WriteToObjectSettings = ({
/>
) : newOnWriteToRecordEntry.fieldType === SalesforceFieldType.CHECKBOX ? (
<Select
size="sm"
className="w-full"
options={checkboxFieldValueOptions}
value={checkboxFieldSelectedOption}
onChange={(e) => {
@@ -212,6 +239,8 @@ const WriteToObjectSettings = ({
/>
) : (
<InputField
size="sm"
className="w-full"
value={newOnWriteToRecordEntry.value as string}
onChange={(e) =>
setNewOnWriteToRecordEntry({
@@ -222,8 +251,10 @@ const WriteToObjectSettings = ({
/>
)}
</div>
<div>
<div className="flex-1">
<Select
size="sm"
className="w-full"
options={whenToWriteToRecordOptions}
value={whenToWriteSelectedOption}
onChange={(e) => {
@@ -237,10 +268,13 @@ const WriteToObjectSettings = ({
}}
/>
</div>
<div className="w-10" />
</div>
</div>
</Section.SubSectionNested>
<Button
className="mt-2"
className="text-subtle mt-2 w-fit"
StartIcon="plus"
color="minimal"
size="sm"
disabled={
!(
@@ -274,7 +308,7 @@ const WriteToObjectSettings = ({
}}>
{t("add_new_field")}
</Button>
</div>
</Section.SubSectionContent>
) : null}
</>
);
@@ -68,6 +68,11 @@ export const BaseScheduledEmail = (
: props.callToAction || <ManageLink attendee={props.attendee} calEvent={props.calEvent} />
}
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
{props.calEvent.rejectionReason && (
<>
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
</>
)}
{props.calEvent.cancellationReason && (
<Info
label={t(
+6
View File
@@ -1,5 +1,11 @@
# @calcom/embed-core
## 1.5.3
### Patch Changes
- Added react-19 as peer dependency
## 1.5.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/embed-core",
"version": "1.5.2",
"version": "1.5.3",
"description": "This is the vanilla JS core script that embeds Cal Link",
"main": "./dist/embed/embed.js",
"types": "./dist/index.d.ts",
+9
View File
@@ -1,5 +1,14 @@
# @calcom/embed-react
## 1.5.3
### Patch Changes
- Added react-19 as peer dependency
- Updated dependencies
- @calcom/embed-core@1.5.3
- @calcom/embed-snippet@1.3.3
## 1.5.2
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@calcom/embed-react",
"sideEffects": false,
"version": "1.5.2",
"version": "1.5.3",
"description": "Embed Cal Link as a React Component",
"license": "SEE LICENSE IN LICENSE",
"repository": {
@@ -1,5 +1,13 @@
# @calcom/embed-snippet
## 1.3.3
### Patch Changes
- Added react-19 as peer dependency
- Updated dependencies
- @calcom/embed-core@1.5.3
## 1.3.2
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@calcom/embed-snippet",
"sideEffects": false,
"version": "1.3.2",
"version": "1.3.3",
"main": "./dist/snippet.umd.js",
"module": "./dist/snippet.es.js",
"description": "Vanilla JS embed snippet that is responsible to fetch @calcom/embed-core and thus show Cal Link as an embed on a page.",
@@ -164,8 +164,8 @@ type BookingDataSchemaGetter =
| typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default;
type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
const buildDryRunBooking = ({
type ReturnTypeCreateBooking = Awaited<ReturnType<typeof createBooking>>;
export const buildDryRunBooking = ({
eventTypeId,
organizerUser,
eventName,
@@ -193,8 +193,11 @@ const buildDryRunBooking = ({
isManagedEventType: boolean;
}) => {
const sanitizedOrganizerUser = {
...organizerUser,
credentials: undefined,
id: organizerUser.id,
name: organizerUser.name,
username: organizerUser.username,
email: organizerUser.email,
timeZone: organizerUser.timeZone,
};
const booking = {
id: -101,
@@ -210,8 +213,6 @@ const buildDryRunBooking = ({
createdAt: new Date(),
updatedAt: new Date(),
attendees: [],
references: [],
payment: [],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
@@ -222,25 +223,18 @@ const buildDryRunBooking = ({
responses: null,
location: null,
paid: false,
destinationCalendar: null,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
rescheduledFrom: null,
fromReschedule: null,
recurringEventId: null,
seatsReferences: [],
workflowReminders: [],
scheduledJobs: [],
rescheduledTo: null,
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
confirmed: false,
isRecurringEvent: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
@@ -248,7 +242,9 @@ const buildDryRunBooking = ({
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
} as CreatedBooking;
references: [],
payment: [],
} satisfies ReturnTypeCreateBooking;
/**
* Troubleshooting data
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { BookingStatus, CreationSource } from "@calcom/prisma/enums";
import { buildDryRunBooking } from "../../handleNewBooking";
describe("buildDryRunBooking", () => {
const baseOrganizerUser = {
id: 1,
name: "Test User",
username: "testuser",
email: "testuser@example.com",
timeZone: "America/New_York",
credentials: [
{
abc: "xyz",
},
],
};
const baseInputs = {
eventTypeId: 10,
organizerUser: baseOrganizerUser,
eventName: "Test Event",
startTime: "2024-01-01T10:00:00.000Z",
endTime: "2024-01-01T11:00:00.000Z",
contactOwnerFromReq: "owner@example.com",
contactOwnerEmail: "owner@example.com",
allHostUsers: [{ id: 1 }, { id: 2 }],
isManagedEventType: false,
};
it("should build a dry run booking with all provided details and nothing more than that", () => {
const { booking, troubleshooterData } = buildDryRunBooking(baseInputs);
const { user, ...bookingExceptUser } = booking;
expect(user).toEqual({
id: baseOrganizerUser.id,
name: baseOrganizerUser.name,
username: baseOrganizerUser.username,
email: baseOrganizerUser.email,
timeZone: baseOrganizerUser.timeZone,
});
expect(bookingExceptUser).toEqual({
id: -101,
uid: "DRY_RUN_UID",
iCalUID: "DRY_RUN_ICAL_UID",
status: BookingStatus.ACCEPTED,
eventTypeId: baseInputs.eventTypeId,
userId: baseOrganizerUser.id,
title: baseInputs.eventName,
startTime: new Date(baseInputs.startTime),
endTime: new Date(baseInputs.endTime),
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
attendees: [],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
idempotencyKey: null,
userPrimaryEmail: null,
description: null,
customInputs: null,
responses: null,
location: null,
paid: false,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
fromReschedule: null,
recurringEventId: null,
scheduledJobs: [],
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
ratingFeedback: null,
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
references: [],
payment: [],
});
expect(troubleshooterData).toEqual({
organizerUserId: baseOrganizerUser.id,
eventTypeId: baseInputs.eventTypeId,
askedContactOwnerEmail: baseInputs.contactOwnerFromReq,
usedContactOwnerEmail: baseInputs.contactOwnerEmail,
allHostUsers: [1, 2],
isManagedEventType: baseInputs.isManagedEventType,
});
});
});
@@ -4,16 +4,23 @@ import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } fro
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
import { usePathname } from "next/navigation";
import { useQueryState, parseAsArrayOf, parseAsJson, parseAsInteger, parseAsString } from "nuqs";
import { useQueryState } from "nuqs";
import { createContext, useCallback, useEffect, useRef, useMemo } from "react";
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
import {
activeFiltersParser,
sortingParser,
columnVisibilityParser,
columnSizingParser,
segmentIdParser,
pageIndexParser,
pageSizeParser,
searchTermParser,
DEFAULT_PAGE_SIZE,
} from "./lib/parsers";
import {
type FilterValue,
ZSorting,
ZColumnVisibility,
ZActiveFilter,
ZColumnSizing,
type FilterSegmentOutput,
type ActiveFilters,
type UseSegments,
@@ -61,12 +68,6 @@ export type DataTableContextType = {
export const DataTableContext = createContext<DataTableContextType | null>(null);
const DEFAULT_ACTIVE_FILTERS: ActiveFilters = [];
const DEFAULT_SORTING: SortingState = [];
const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {};
const DEFAULT_COLUMN_SIZING: ColumnSizingState = {};
const DEFAULT_PAGE_SIZE = 10;
interface DataTableProviderProps {
useSegments?: UseSegments;
tableIdentifier?: string;
@@ -83,26 +84,17 @@ export function DataTableProvider({
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
}: DataTableProviderProps) {
const filterToOpen = useRef<string | undefined>(undefined);
const [activeFilters, setActiveFilters] = useQueryState(
"activeFilters",
parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(DEFAULT_ACTIVE_FILTERS)
);
const [sorting, setSorting] = useQueryState(
"sorting",
parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault(DEFAULT_SORTING)
);
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
"cols",
parseAsJson(ZColumnVisibility.parse).withDefault(DEFAULT_COLUMN_VISIBILITY)
columnVisibilityParser
);
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>(
"widths",
parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING)
);
const [segmentId, setSegmentId] = useQueryState("segment", parseAsInteger.withDefault(-1));
const [pageIndex, setPageIndex] = useQueryState("page", parseAsInteger.withDefault(0));
const [pageSize, setPageSize] = useQueryState("size", parseAsInteger.withDefault(defaultPageSize));
const [searchTerm, setSearchTerm] = useQueryState("q", parseAsString.withDefault(""));
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
const [segmentId, setSegmentId] = useQueryState("segment", segmentIdParser);
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const setDebouncedSearchTerm = useMemo(
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
@@ -39,6 +39,7 @@ export type DataTablePropsFromWrapper<TData> = {
headerClassName?: string;
rowClassName?: string;
paginationMode?: "infinite" | "standard";
hasWrapperContext?: boolean;
};
export type DataTableProps<TData> = DataTablePropsFromWrapper<TData> & {
@@ -64,6 +65,7 @@ export function DataTable<TData>({
headerClassName,
rowClassName,
paginationMode = "infinite",
hasWrapperContext = false,
...rest
}: DataTableProps<TData> & React.ComponentPropsWithoutRef<"div">) {
const { rows } = table.getRowModel();
@@ -104,7 +106,10 @@ export function DataTable<TData>({
return (
<div
className={classNames("grid", className)}
className={classNames(
!hasWrapperContext ? "grid" : "bg-muted grid rounded-xl px-0.5 pb-0.5",
className
)}
style={{
gridTemplateRows: "auto 1fr auto",
gridTemplateAreas: "'header' 'body' 'footer'",
@@ -129,20 +134,23 @@ export function DataTable<TData>({
onScroll={onScroll}
className={classNames(
"relative overflow-auto",
"scrollbar-thin border-subtle relative rounded-md border",
"scrollbar-thin relative rounded-md ",
paginationMode === "infinite" && "h-[80dvh]", // Set a fixed height for the container
containerClassName
)}
style={{ gridArea: "body" }}>
<TableNew
className="data-table grid border-0"
className={classNames(
"data-table grid border-0",
!hasWrapperContext && "bg-muted rounded-xl px-0.5 pb-0.5"
)}
style={{
...columnSizingVars,
...(Boolean(enableColumnResizing) && { width: table.getTotalSize() }),
}}>
<TableHeader className={classNames("sticky top-0 z-10", headerClassName)}>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full border-none">
{headerGroup.headers.map((header: Header<TData, unknown>) => {
const { column } = header;
return (
@@ -155,7 +163,6 @@ export function DataTable<TData>({
}}
className={classNames(
"relative flex shrink-0 items-center",
"bg-subtle",
column.getIsPinned() && "top-0 z-20 sm:sticky"
)}>
<TableHeadLabel header={header} />
@@ -164,7 +171,7 @@ export function DataTable<TData>({
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={classNames(
"group absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-[0.1] hover:opacity-50",
"group absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-0 hover:opacity-50",
header.column.getIsResizing() && "!opacity-75"
)}>
<div className="bg-inverted mx-auto h-full w-[1px]" />
@@ -279,7 +286,10 @@ function DataTableBody<TData>({
}
return (
<TableBody className="relative grid" data-testid={testId} style={{ height: tableHeight }}>
<TableBody
className="border-subtle relative grid overflow-hidden rounded-xl border"
data-testid={testId}
style={{ height: tableHeight }}>
{rowsToRender.map(({ row, virtualItem }) => (
<TableRow
ref={virtualItem ? (node) => rowVirtualizer.measureElement(node) : undefined}
@@ -307,7 +317,7 @@ function DataTableBody<TData>({
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
}}
className={classNames(
"flex shrink-0 items-center overflow-hidden",
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-0",
column.getIsPinned() &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
@@ -351,7 +361,7 @@ const TableHeadLabel = ({ header }: { header: Header<any, any> }) => {
open && "bg-muted"
)}>
<div
className="truncate"
className="text-default truncate text-sm leading-none"
title={
typeof header.column.columnDef.header === "string" ? header.column.columnDef.header : undefined
}>
@@ -1,6 +1,6 @@
"use client";
import type { VisibilityState } from "@tanstack/react-table";
import type { Row, VisibilityState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useEffect, useRef } from "react";
@@ -22,6 +22,7 @@ type BaseDataTableWrapperProps<TData> = Omit<
EmptyView?: React.ReactNode;
LoaderView?: React.ReactNode;
tableContainerRef?: React.RefObject<HTMLDivElement>;
onRowMouseclick?: (row: Row<TData>) => void;
};
type InfinitePaginationProps<TData> = BaseDataTableWrapperProps<TData> & {
@@ -61,6 +62,7 @@ export function DataTableWrapper<TData>({
children,
tableContainerRef: externalRef,
paginationMode,
onRowMouseclick,
}: DataTableWrapperProps<TData>) {
const internalRef = useRef<HTMLDivElement>(null);
const tableContainerRef = externalRef || internalRef;
@@ -130,13 +132,15 @@ export function DataTableWrapper<TData>({
headerClassName={headerClassName}
rowClassName={rowClassName}
paginationMode={paginationMode}
onRowMouseclick={onRowMouseclick}
hasWrapperContext={true}
onScroll={
paginationMode === "infinite"
? (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) =>
fetchMoreOnBottomReached(e.target as HTMLDivElement)
: undefined
}>
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
<div style={{ gridArea: "footer" }} className="px-3 py-2">
<DataTablePagination<TData>
table={table}
totalRowCount={totalRowCount}
+1
View File
@@ -1,6 +1,7 @@
export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/serializers";
export * from "./DataTableProvider";
export * from "./hooks/useColumnResizing";
export * from "./hooks";
@@ -0,0 +1,24 @@
import type { SortingState, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
import { parseAsArrayOf, parseAsJson, parseAsInteger, parseAsString } from "nuqs";
import { ZActiveFilter, ZSorting, ZColumnVisibility, ZColumnSizing } from "./types";
import type { ActiveFilters } from "./types";
const DEFAULT_ACTIVE_FILTERS: ActiveFilters = [] as ActiveFilters;
const DEFAULT_SORTING: SortingState = [] as SortingState;
const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {};
const DEFAULT_COLUMN_SIZING: ColumnSizingState = {};
export const DEFAULT_PAGE_SIZE = 10;
export const activeFiltersParser = parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(
DEFAULT_ACTIVE_FILTERS
);
export const sortingParser = parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault(DEFAULT_SORTING);
export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withDefault(
DEFAULT_COLUMN_VISIBILITY
);
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
export const segmentIdParser = parseAsInteger.withDefault(-1);
export const pageIndexParser = parseAsInteger.withDefault(0);
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
export const searchTermParser = parseAsString.withDefault("");
@@ -0,0 +1,28 @@
import { createSerializer } from "nuqs";
import {
activeFiltersParser,
sortingParser,
columnVisibilityParser,
columnSizingParser,
segmentIdParser,
pageIndexParser,
pageSizeParser,
searchTermParser,
} from "./parsers";
/**
* Serializer for data table query parameters
* Used to generate URL query parameters for data tables
* Based on the parameters used in DataTableProvider
*/
export const dataTableQueryParamsSerializer = createSerializer({
activeFilters: activeFiltersParser,
sorting: sortingParser,
cols: columnVisibilityParser,
widths: columnSizingParser,
segment: segmentIdParser,
page: pageIndexParser,
size: pageSizeParser,
q: searchTermParser,
});
@@ -19,10 +19,10 @@ import type { Dispatch, SetStateAction } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import {
DataTable,
DataTableProvider,
DataTableToolbar,
DataTableFilters,
DataTableWrapper,
DataTableSelectionBar,
useDataTable,
useFetchMoreOnBottomReached,
@@ -305,7 +305,6 @@ function MemberListContent(props: Props) {
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
@@ -660,18 +659,27 @@ function MemberListContent(props: Props) {
return (
<>
<DataTable
<DataTableWrapper
testId="team-member-list-container"
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<div className="flex w-full gap-2">
paginationMode="infinite"
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
totalRowCount={totalRowCount}
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
{isAdminOrOwner && (
<DataTableToolbar.CTA
type="button"
@@ -682,12 +690,8 @@ function MemberListContent(props: Props) {
{t("add")}
</DataTableToolbar.CTA>
)}
</div>
<div className="flex gap-2 justify-self-start">
<DataTableFilters.ActiveFilters table={table} />
</div>
</DataTableToolbar.Root>
</>
}>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
<DataTableSelectionBar.Root className="!bottom-[7.3rem] md:!bottom-32">
<DynamicLink table={table} domain={domain} />
@@ -715,7 +719,7 @@ function MemberListContent(props: Props) {
/>
</DataTableSelectionBar.Root>
)}
</DataTable>
</DataTableWrapper>
{state.deleteMember.showModal && (
<Dialog
open={true}
@@ -13,15 +13,13 @@ import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { SchedulingType, WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
import { SchedulingType, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import {
getAllRemindersToCancel,
getAllRemindersToDelete,
getAllUnscheduledReminders,
select,
} from "../lib/getWorkflowReminders";
import { sendOrScheduleWorkflowEmails } from "../lib/reminders/providers/emailProvider";
import {
@@ -45,33 +43,29 @@ export async function handler(req: NextRequest) {
const isSendgridEnabled = process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL;
if (isSendgridEnabled) {
// delete batch_ids with already past scheduled date from scheduled_sends
const remindersToDelete: { referenceId: string | null }[] = await getAllRemindersToDelete();
const remindersToDelete: { referenceId: string | null; id: number }[] = await getAllRemindersToDelete();
const deletePromises: Promise<any>[] = [];
for (const reminder of remindersToDelete) {
const deletePromise = deleteScheduledSend(reminder.referenceId);
deletePromises.push(deletePromise);
}
Promise.allSettled(deletePromises).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
logger.error(`Error deleting batch id from scheduled_sends: ${result.reason}`);
const handlePastCancelledReminders = remindersToDelete.map(async (reminder) => {
try {
if (reminder.referenceId) {
await deleteScheduledSend(reminder.referenceId);
}
});
} catch (err) {
logger.error(`Error deleting scheduled send (ref: ${reminder.referenceId}): ${err}`);
}
try {
await prisma.workflowReminder.update({
where: { id: reminder.id },
data: { referenceId: null },
});
} catch (err) {
logger.error(`Error updating reminder (id: ${reminder.id}): ${err}`);
}
});
await Promise.allSettled(handlePastCancelledReminders);
}
//delete workflow reminders with past scheduled date
await prisma.workflowReminder.deleteMany({
where: {
method: WorkflowMethods.EMAIL,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
});
if (isSendgridEnabled) {
//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
@@ -106,20 +100,7 @@ export async function handler(req: NextRequest) {
// schedule all unscheduled reminders within the next 72 hours
const sendEmailPromises: Promise<any>[] = [];
let unscheduledReminders: PartialWorkflowReminder[] = [];
if (isSendgridEnabled) {
unscheduledReminders = await getAllUnscheduledReminders();
} else {
unscheduledReminders = (await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.EMAIL,
scheduled: false,
OR: [{ cancelled: false }, { cancelled: null }],
},
select,
})) as PartialWorkflowReminder[];
}
const unscheduledReminders = await getAllUnscheduledReminders();
if (!unscheduledReminders.length) {
return NextResponse.json({ message: "No Emails to schedule" }, { status: 200 });
@@ -78,12 +78,15 @@ async function getWorkflowReminders<T extends Prisma.WorkflowReminderSelect>(
return filteredWorkflowReminders;
}
type RemindersToDeleteType = { referenceId: string | null };
type RemindersToDeleteType = { referenceId: string | null; id: number };
export async function getAllRemindersToDelete(): Promise<RemindersToDeleteType[]> {
const whereFilter: Prisma.WorkflowReminderWhereInput = {
method: WorkflowMethods.EMAIL,
cancelled: true,
referenceId: {
not: null,
},
scheduledDate: {
lt: dayjs().toISOString(),
},
@@ -91,6 +94,7 @@ export async function getAllRemindersToDelete(): Promise<RemindersToDeleteType[]
const select = Prisma.validator<Prisma.WorkflowReminderSelect>()({
referenceId: true,
id: true,
});
const remindersToDelete = await getWorkflowReminders(whereFilter, select);
@@ -289,8 +289,6 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
/**
* @deprecated only needed for SendGrid, use SMTP with tasker instead
*/
const sendgridBatchId = await getBatchId();
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
@@ -316,8 +314,15 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
!scheduledDate.isAfter(currentDate.add(2, "hour"))
) {
try {
const sendgridBatchId = await getBatchId();
// If sendEmail failed then workflowReminer will not be created, failing E2E tests
await sendSendgridMail({ ...mailData, to: sendTo, sendAt: scheduledDate.unix() });
await sendSendgridMail({
...mailData,
to: sendTo,
sendAt: scheduledDate.unix(),
batchId: sendgridBatchId,
});
if (!isMandatoryReminder) {
await prisma.workflowReminder.create({
@@ -390,15 +395,19 @@ export const deleteScheduledEmailReminder = async (reminderId: number) => {
const { uuid, referenceId } = workflowReminder;
if (uuid) {
const taskId = await tasker.cancelWithReference(uuid, "sendWorkflowEmails");
if (taskId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
try {
const taskId = await tasker.cancelWithReference(uuid, "sendWorkflowEmails");
if (taskId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
return;
return;
}
} catch (error) {
log.error(`Error canceling/deleting reminder with tasker. Error: ${error}`);
}
}
@@ -13,6 +13,7 @@ import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Section } from "@calcom/ui/components/section";
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
@@ -91,7 +92,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
return (
<>
<div>
<div className="before:border-0">
<div className="flex flex-col gap-4 before:border-0">
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity={appsDisableProps.isLocked ? "neutral" : "info"}
@@ -151,14 +152,13 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
})}
</div>
</div>
{/* TODO: Add back after salesforce v3 dev */}
{!appsDisableProps.disabled && (
<div className="bg-muted mt-6 rounded-md p-8">
<div className="bg-muted mt-4 rounded-2xl p-4">
{!isPending && notInstalledApps?.length ? (
<>
<h2 className="text-emphasis mb-2 text-xl font-semibold leading-5 tracking-[0.01em]">
{t("available_apps_lower_case")}
</h2>
<p className="text-default mb-6 text-sm font-normal">
<div className="mb-4 flex flex-col">
<Section.Title>{t("available_apps_lower_case")}</Section.Title>
<Section.Description>
<ServerTrans
t={t}
i18nKey="available_apps_desc"
@@ -168,10 +168,10 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
</Link>,
]}
/>
</p>
</>
</Section.Description>
</div>
) : null}
<div className="bg-default border-subtle divide-subtle divide-y rounded-md border before:border-0">
<div className="bg-default border-muted flex flex-col gap-4 rounded-xl border p-3">
{notInstalledApps?.map((app) => (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
@@ -0,0 +1,265 @@
import { describe, expect, beforeEach, afterEach, it } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
describe("BookingDenormalized", () => {
let userId: number;
let eventTypeId: number;
let bookingId: number;
const randomId = Math.floor(Math.random() * 1000000);
const email = `booking-denorm-${randomId}@example.com`;
beforeEach(async () => {
// Create test user
const user = await prisma.user.create({
data: {
email,
username: `booking-denorm-testuser-${randomId}`,
name: "Test User",
},
});
userId = user.id;
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event",
slug: "test-event",
length: 60,
userId: user.id,
},
});
eventTypeId = eventType.id;
// Create test booking
const booking = await prisma.booking.create({
data: {
uid: `booking-denorm-${randomId}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user.id,
eventTypeId: eventType.id,
status: BookingStatus.PENDING,
},
});
bookingId = booking.id;
});
afterEach(async () => {
// Clean up test data in reverse order of creation
if (bookingId) {
await prisma.booking.deleteMany({
where: { id: bookingId },
});
}
if (eventTypeId) {
await prisma.eventType.deleteMany({
where: { id: eventTypeId },
});
}
if (userId) {
await prisma.user.deleteMany({
where: { id: userId },
});
}
});
describe("Creation and Updates", () => {
it("should create denormalized entry when booking is created", async () => {
const denormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
expect(denormalizedBooking.eventTypeId).toBe(eventTypeId);
expect(denormalizedBooking.userId).toBe(userId);
expect(denormalizedBooking.title).toBe("Test Booking");
expect(denormalizedBooking.eventLength).toBe(60);
expect(denormalizedBooking.userName).toBe("Test User");
});
it("should update denormalized entry when booking is updated", async () => {
await prisma.booking.update({
where: { id: bookingId },
data: {
title: "Updated Booking",
status: BookingStatus.CANCELLED,
},
});
const updatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
expect(updatedDenormalizedBooking.title).toBe("Updated Booking");
expect(updatedDenormalizedBooking.status).toBe(BookingStatus.CANCELLED);
});
it("should update denormalized entry when user is updated", async () => {
await prisma.user.update({
where: { id: userId },
data: {
name: "Updated User",
email: "updated@example.com",
},
});
const userUpdatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
expect(userUpdatedDenormalizedBooking.userName).toBe("Updated User");
expect(userUpdatedDenormalizedBooking.userEmail).toBe("updated@example.com");
});
it("should update denormalized entry when event type is updated", async () => {
await prisma.eventType.update({
where: { id: eventTypeId },
data: {
length: 30,
schedulingType: SchedulingType.ROUND_ROBIN,
},
});
const eventTypeUpdatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
expect(eventTypeUpdatedDenormalizedBooking.eventLength).toBe(30);
});
});
describe("Deletion Scenarios", () => {
it("should delete denormalized entry when booking is deleted", async () => {
await prisma.booking.delete({
where: { id: bookingId },
});
const deletedDenormalizedBooking = await prisma.bookingDenormalized.findUnique({
where: { id: bookingId },
});
expect(deletedDenormalizedBooking).toBeNull();
});
it("should update denormalized entries when event type is deleted", async () => {
// Create an additional booking for this test
const booking2 = await prisma.booking.create({
data: {
uid: `test-booking-${Math.floor(Math.random() * 1000000)}`,
title: "Test Booking 2",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: userId,
eventTypeId: eventTypeId,
status: BookingStatus.PENDING,
},
});
await prisma.eventType.delete({
where: { id: eventTypeId },
});
const eventTypeDeletedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: booking2.id },
});
expect(eventTypeDeletedDenormalizedBooking.eventTypeId).toBeNull();
expect(eventTypeDeletedDenormalizedBooking.eventLength).toBeNull();
// Clean up the additional booking
await prisma.booking.delete({
where: { id: booking2.id },
});
});
it("should delete denormalized entries when user is deleted", async () => {
// Create an additional booking for this test
const booking2 = await prisma.booking.create({
data: {
uid: `test-booking-${Math.floor(Math.random() * 1000000)}`,
title: "Test Booking 2",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: userId,
eventTypeId: eventTypeId,
status: BookingStatus.PENDING,
},
});
await prisma.user.delete({
where: { id: userId },
});
const userDeletedDenormalizedBooking1 = await prisma.bookingDenormalized.findUnique({
where: { id: bookingId },
});
const userDeletedDenormalizedBooking2 = await prisma.bookingDenormalized.findUnique({
where: { id: booking2.id },
});
expect(userDeletedDenormalizedBooking1).toBeNull();
expect(userDeletedDenormalizedBooking2).toBeNull();
});
});
describe("Data Integrity", () => {
it("should maintain correct relationships across all tables", async () => {
const denormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
const originalBooking = await prisma.booking.findUniqueOrThrow({
where: { id: bookingId },
});
// Verify relationships and data consistency
expect(denormalizedBooking.uid).toBe(originalBooking.uid);
expect(denormalizedBooking.eventTypeId).toBe(originalBooking.eventTypeId);
expect(denormalizedBooking.userId).toBe(originalBooking.userId);
expect(denormalizedBooking.status).toBe(originalBooking.status);
expect(denormalizedBooking.startTime.getTime()).toBe(originalBooking.startTime.getTime());
expect(denormalizedBooking.endTime.getTime()).toBe(originalBooking.endTime.getTime());
});
});
describe("Edge Cases", () => {
it("should handle special characters in text fields", async () => {
const specialChars = "Test 'quotes' and \"double quotes\" and \\ backslashes";
await prisma.booking.update({
where: { id: bookingId },
data: {
title: specialChars,
description: specialChars,
},
});
const denormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
expect(denormalizedBooking.title).toBe(specialChars);
expect(denormalizedBooking.description).toBe(specialChars);
});
it("should handle null values in optional fields", async () => {
await prisma.booking.update({
where: { id: bookingId },
data: {
description: null,
location: null,
eventTypeId: null,
},
});
const denormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
where: { id: bookingId },
});
expect(denormalizedBooking.description).toBeNull();
expect(denormalizedBooking.location).toBeNull();
expect(denormalizedBooking.eventTypeId).toBeNull();
});
});
});
@@ -113,7 +113,6 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
@@ -257,7 +257,6 @@ function UserListTableContent() {
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
@@ -41,7 +41,6 @@ export const apiToInternalintegrationsMapping = {
"discord-video": "integrations:discord_video",
"demodesk-video": "integrations:demodesk_video",
"campfire-video": "integrations:campfire_video",
"around-video": "integrations:around_video",
} as const;
function transformLocation<T extends InputLocation_2024_06_14>(location: T) {
@@ -43,7 +43,6 @@ const internalToApiIntegrationsMapping: Record<string, OutputIntegration_2024_06
"integrations:discord_video": "discord-video",
"integrations:demodesk_video": "demodesk-video",
"integrations:campfire_video": "campfire-video",
"integrations:around_video": "around-video",
};
export function transformLocationsInternalToApi(internalLocations: InternalLocation[] | undefined) {
@@ -36,7 +36,6 @@ const integrationsApiUnavailable = {
"discord-video": z.literal("integrations:discord_video"),
"demodesk-video": z.literal("integrations:demodesk_video"),
"campfire-video": z.literal("integrations:campfire_video"),
"around-video": z.literal("integrations:around_video"),
};
export const integrationsApiToInternalMappingSchema = {
@@ -75,7 +74,6 @@ const OrganizerIntegrationSchema = z.object({
integrationsApiToInternalMappingSchema["discord-video"],
integrationsApiToInternalMappingSchema["demodesk-video"],
integrationsApiToInternalMappingSchema["campfire-video"],
integrationsApiToInternalMappingSchema["around-video"],
]),
link: z.string().url().optional(),
credentialId: z.number().optional(),
+56 -30
View File
@@ -182,6 +182,18 @@ export type GetUserAvailabilityInitialData = {
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
})[];
busyTimesFromLimitsBookings: EventBusyDetails[];
busyTimesFromLimits?: Map<number, EventBusyDetails[]>;
eventTypeForLimits?: {
id: number;
bookingLimits?: unknown;
durationLimits?: unknown;
} | null;
teamBookingLimits?: Map<number, EventBusyDetails[]>;
teamForBookingLimits?: {
id: number;
bookingLimits?: unknown;
includeManagedEventsInLimits: boolean;
} | null;
};
export type GetAvailabilityUser = NonNullable<GetUserAvailabilityInitialData["user"]>;
@@ -352,40 +364,49 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const durationLimits = parseDurationLimit(eventType?.durationLimits);
const busyTimesFromLimits =
eventType && (bookingLimits || durationLimits)
? await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
dateFrom.tz(timeZone),
dateTo.tz(timeZone),
duration,
eventType,
initialData?.busyTimesFromLimitsBookings ?? [],
timeZone,
initialData?.rescheduleUid ?? undefined
)
: [];
let busyTimesFromLimits: EventBusyDetails[] = [];
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || [];
} else if (eventType && (bookingLimits || durationLimits)) {
// Fall back to individual query if not available in initialData
busyTimesFromLimits = await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
dateFrom.tz(timeZone),
dateTo.tz(timeZone),
duration,
eventType,
initialData?.busyTimesFromLimitsBookings ?? [],
timeZone,
initialData?.rescheduleUid ?? undefined
);
}
const teamForBookingLimits =
initialData?.teamForBookingLimits ??
eventType?.team ??
(eventType?.parent?.team?.includeManagedEventsInLimits ? eventType?.parent?.team : null);
const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits);
const busyTimesFromTeamLimits =
teamForBookingLimits && teamBookingLimits
? await getBusyTimesFromTeamLimits(
user,
teamBookingLimits,
dateFrom.tz(timeZone),
dateTo.tz(timeZone),
teamForBookingLimits.id,
teamForBookingLimits.includeManagedEventsInLimits,
timeZone,
initialData?.rescheduleUid ?? undefined
)
: [];
let busyTimesFromTeamLimits: EventBusyDetails[] = [];
if (initialData?.teamBookingLimits && teamForBookingLimits) {
busyTimesFromTeamLimits = initialData.teamBookingLimits.get(user.id) || [];
} else if (teamForBookingLimits && teamBookingLimits) {
// Fall back to individual query if not available in initialData
busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits(
user,
teamBookingLimits,
dateFrom.tz(timeZone),
dateTo.tz(timeZone),
teamForBookingLimits.id,
teamForBookingLimits.includeManagedEventsInLimits,
timeZone,
initialData?.rescheduleUid ?? undefined
);
}
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
@@ -598,10 +619,15 @@ export const getPeriodStartDatesBetween = (
return monitorCallbackSync(_getPeriodStartDatesBetween, ...args);
};
const _getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit) => {
const _getPeriodStartDatesBetween = (
dateFrom: Dayjs,
dateTo: Dayjs,
period: IntervalLimitUnit,
timeZone?: string
) => {
const dates = [];
let startDate = dayjs(dateFrom).startOf(period);
const endDate = dayjs(dateTo).endOf(period);
let startDate = timeZone ? dayjs(dateFrom).tz(timeZone).startOf(period) : dayjs(dateFrom).startOf(period);
const endDate = timeZone ? dayjs(dateTo).tz(timeZone).endOf(period) : dayjs(dateTo).endOf(period);
while (startDate.isBefore(endDate)) {
dates.push(startDate);
+17 -15
View File
@@ -14,31 +14,32 @@ export default class LimitManager {
/**
* Creates a busy map key
*/
private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey {
return `${unit}-${start.startOf(unit).toISOString()}`;
private static createKey(start: Dayjs, unit: IntervalLimitUnit, timeZone?: string): BusyMapKey {
const tzStart = timeZone ? start.tz(timeZone) : start;
return `${unit}-${tzStart.startOf(unit).toISOString()}`;
}
/**
* Checks if already marked busy by ancestors or siblings
*/
isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) {
if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true;
isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit, timeZone?: string) {
if (this.busyMap.has(LimitManager.createKey(start, "year", timeZone))) return true;
if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) {
if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month", timeZone))) {
return true;
} else if (
unit === "week" &&
// weeks can be part of two months
((this.busyMap.has(LimitManager.createKey(start, "month")) &&
this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) ||
this.busyMap.has(LimitManager.createKey(start, "week")))
((this.busyMap.has(LimitManager.createKey(start, "month", timeZone)) &&
this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month", timeZone))) ||
this.busyMap.has(LimitManager.createKey(start, "week", timeZone)))
) {
return true;
} else if (
unit === "day" &&
(this.busyMap.has(LimitManager.createKey(start, "month")) ||
this.busyMap.has(LimitManager.createKey(start, "week")) ||
this.busyMap.has(LimitManager.createKey(start, "day")))
(this.busyMap.has(LimitManager.createKey(start, "month", timeZone)) ||
this.busyMap.has(LimitManager.createKey(start, "week", timeZone)) ||
this.busyMap.has(LimitManager.createKey(start, "day", timeZone)))
) {
return true;
} else {
@@ -49,10 +50,11 @@ export default class LimitManager {
/**
* Adds a new busy time
*/
addBusyTime(start: Dayjs, unit: IntervalLimitUnit) {
this.busyMap.set(`${unit}-${start.toISOString()}`, {
start: start.toISOString(),
end: start.endOf(unit).toISOString(),
addBusyTime(start: Dayjs, unit: IntervalLimitUnit, timeZone?: string) {
const tzStart = timeZone ? start.tz(timeZone) : start;
this.busyMap.set(`${unit}-${tzStart.toISOString()}`, {
start: tzStart.toISOString(),
end: tzStart.endOf(unit).toISOString(),
});
}
@@ -12,6 +12,7 @@ import type { EventBusyDetails } from "@calcom/types/Calendar";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
import type { IntervalLimit } from "../intervalLimitSchema";
import LimitManager from "../limitManager";
import { isBookingWithinPeriod } from "../utils";
import { checkBookingLimit } from "./checkBookingLimits";
export const getBusyTimesFromLimits = async (
@@ -64,6 +65,7 @@ const _getBusyTimesFromLimits = async (
duration,
eventType,
limitManager,
timeZone,
rescheduleUid
);
performance.mark("durationLimitsEnd");
@@ -147,7 +149,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
for (const booking of bookings) {
// consider booking part of period independent of end date
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone || "UTC")) {
continue;
}
totalBookings++;
@@ -174,6 +176,7 @@ const _getBusyTimesFromDurationLimits = async (
duration: number | undefined,
eventType: NonNullable<EventType>,
limitManager: LimitManager,
timeZone: string,
rescheduleUid?: string
) => {
for (const key of descendingLimitKeys) {
@@ -215,7 +218,7 @@ const _getBusyTimesFromDurationLimits = async (
for (const booking of bookings) {
// consider booking part of period independent of end date
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone || "UTC")) {
continue;
}
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
+74
View File
@@ -0,0 +1,74 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { EventBusyDetails } from "@calcom/types/Calendar";
import type { IntervalLimitUnit } from "./intervalLimitSchema";
/**
* Extracts date parameters from a booking and period
* @param booking The booking to extract date parameters from
* @param periodStart The start of the period
* @param periodEnd The end of the period
* @param timeZone The timezone to use
* @returns Object containing extracted date parameters
*/
export function extractDateParameters(
booking: EventBusyDetails,
periodStart: Dayjs,
periodEnd: Dayjs,
timeZone: string
) {
const bookingStart = dayjs(booking.start).tz(timeZone);
const bookingDay = bookingStart.format("YYYY-MM-DD");
const periodStartDay = periodStart.format("YYYY-MM-DD");
const periodEndDay = periodEnd.format("YYYY-MM-DD");
return {
bookingStart,
bookingDay,
periodStartDay,
periodEndDay,
};
}
/**
* Checks if a booking is within a given period
* @param booking The booking to check
* @param periodStart The start of the period
* @param periodEnd The end of the period
* @param timeZone The timezone to use
* @returns Boolean indicating if the booking is within the period
*/
export function isBookingWithinPeriod(
booking: EventBusyDetails,
periodStart: Dayjs,
periodEnd: Dayjs,
timeZone: string
) {
const { bookingDay, periodStartDay, periodEndDay } = extractDateParameters(
booking,
periodStart,
periodEnd,
timeZone
);
return !(bookingDay < periodStartDay || bookingDay > periodEndDay);
}
/**
* Gets unit from a busy time's start and end dates
* @param start The start date
* @param end The end date
* @returns The appropriate interval limit unit
*/
export function getUnitFromBusyTime(start: Dayjs, end: Dayjs): IntervalLimitUnit {
if (end.diff(start, "year") >= 1) {
return "year";
} else if (end.diff(start, "month") >= 1) {
return "month";
} else if (end.diff(start, "week") >= 1) {
return "week";
} else {
return "day";
}
}
+128
View File
@@ -17,6 +17,22 @@ type TeamBookingsParamsBase = {
shouldReturnCount?: boolean;
};
type TeamBookingsMultipleUsersParamsBase = {
users: { id: number; email: string }[];
teamId: number;
startDate: Date;
endDate: Date;
excludedUid?: string | null;
includeManagedEvents: boolean;
shouldReturnCount?: boolean;
};
type TeamBookingsMultipleUsersParamsWithCount = TeamBookingsMultipleUsersParamsBase & {
shouldReturnCount: true;
};
type TeamBookingsMultipleUsersParamsWithoutCount = TeamBookingsMultipleUsersParamsBase;
type TeamBookingsParamsWithCount = TeamBookingsParamsBase & {
shouldReturnCount: true;
};
@@ -666,4 +682,116 @@ export class BookingRepository {
},
});
}
static async getAllAcceptedTeamBookingsOfUsers(
params: TeamBookingsMultipleUsersParamsWithCount
): Promise<number>;
static async getAllAcceptedTeamBookingsOfUsers(
params: TeamBookingsMultipleUsersParamsWithoutCount
): Promise<Array<Booking>>;
static async getAllAcceptedTeamBookingsOfUsers(params: TeamBookingsMultipleUsersParamsBase) {
const { users, teamId, startDate, endDate, excludedUid, shouldReturnCount, includeManagedEvents } =
params;
const baseWhere: Prisma.BookingWhereInput = {
status: BookingStatus.ACCEPTED,
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
...(excludedUid && {
uid: {
not: excludedUid,
},
}),
};
const userIds = users.map((user) => user.id);
const userEmails = users.map((user) => user.email);
const whereCollectiveRoundRobinOwner: Prisma.BookingWhereInput = {
...baseWhere,
userId: {
in: userIds,
},
eventType: {
teamId,
},
};
const whereCollectiveRoundRobinBookingsAttendee: Prisma.BookingWhereInput = {
...baseWhere,
attendees: {
some: {
email: {
in: userEmails,
},
},
},
eventType: {
teamId,
},
};
const whereManagedBookings: Prisma.BookingWhereInput = {
...baseWhere,
userId: {
in: userIds,
},
eventType: {
parent: {
teamId,
},
},
};
if (shouldReturnCount) {
const collectiveRoundRobinBookingsOwner = await prisma.booking.count({
where: whereCollectiveRoundRobinOwner,
});
const collectiveRoundRobinBookingsAttendee = await prisma.booking.count({
where: whereCollectiveRoundRobinBookingsAttendee,
});
let managedBookings = 0;
if (includeManagedEvents) {
managedBookings = await prisma.booking.count({
where: whereManagedBookings,
});
}
const totalNrOfBooking =
collectiveRoundRobinBookingsOwner + collectiveRoundRobinBookingsAttendee + managedBookings;
return totalNrOfBooking;
}
const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({
where: whereCollectiveRoundRobinOwner,
});
const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({
where: whereCollectiveRoundRobinBookingsAttendee,
});
let managedBookings: typeof collectiveRoundRobinBookingsAttendee = [];
if (includeManagedEvents) {
managedBookings = await prisma.booking.findMany({
where: whereManagedBookings,
});
}
return [
...collectiveRoundRobinBookingsOwner,
...collectiveRoundRobinBookingsAttendee,
...managedBookings,
];
}
}
@@ -63,7 +63,6 @@ export const supportedIntegrations = [
"discord-video",
"demodesk-video",
"campfire-video",
"around-video",
] as const;
export type Integration_2024_08_13 = (typeof supportedIntegrations)[number];
@@ -54,7 +54,6 @@ const integrationsValues = [
"discord-video",
"demodesk-video",
"campfire-video",
"around-video",
] as const;
export type OutputIntegration_2024_06_14 = (typeof integrationsValues)[number];
@@ -23,7 +23,7 @@ export class RoutingFormResponseOutput {
@ApiProperty()
@IsInt()
@Expose()
id!: string;
id!: number;
@ApiProperty()
@IsInt()

Some files were not shown because too many files have changed in this diff Show More