Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e481f2921 | ||
|
|
0e6203ac3c | ||
|
|
624d6d7001 | ||
|
|
ec03ab632e | ||
|
|
c81b8145b5 | ||
|
|
fa35cc5210 | ||
|
|
1eb6ff3e65 | ||
|
|
fc4ecf500a | ||
|
|
9d4522825b | ||
|
|
e91bf53d80 | ||
|
|
79b319c1dd | ||
|
|
9197a73b31 | ||
|
|
ec3656e646 | ||
|
|
ae4b807500 | ||
|
|
34a9872df1 | ||
|
|
91a480b83a | ||
|
|
db19bb3d51 | ||
|
|
9afdb1ad7e | ||
|
|
78c2a69389 | ||
|
|
5a483e75c9 | ||
|
|
bb9c4bb8f3 | ||
|
|
02fdc1361c | ||
|
|
573132198e | ||
|
|
9133bc5b16 | ||
|
|
94ae3a5a73 | ||
|
|
0757b00db7 | ||
|
|
3402f79912 | ||
|
|
56ef5d667c | ||
|
|
bb9d99c3e1 | ||
|
|
1aac14056e | ||
|
|
7af46cc7ab | ||
|
|
37cec89bf6 | ||
|
|
67843adab9 | ||
|
|
64297f027c | ||
|
|
9fe5ff7721 | ||
|
|
3076aca457 | ||
|
|
5a59bb86cd | ||
|
|
17ab088af8 | ||
|
|
b81a307bd9 | ||
|
|
b312955838 | ||
|
|
003aaaa18b | ||
|
|
3b19c064fd | ||
|
|
e26a57bf25 | ||
|
|
b5585b3974 |
@@ -306,6 +306,224 @@ import { Button } from "@calcom/ui/components/button";
|
||||
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
||||
|
||||
## Repository Method Conventions
|
||||
|
||||
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
|
||||
|
||||
### Method Naming Rules
|
||||
|
||||
**1. Don't include the repository's entity name in method names**
|
||||
|
||||
Method names should be concise and avoid redundancy since the repository class name already indicates the entity type.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Concise method names
|
||||
class BookingRepository {
|
||||
findById(id: string) { ... }
|
||||
findByUserId(userId: string) { ... }
|
||||
create(data: BookingCreateInput) { ... }
|
||||
delete(id: string) { ... }
|
||||
}
|
||||
|
||||
// ❌ Bad - Redundant entity name in methods
|
||||
class BookingRepository {
|
||||
findBookingById(id: string) { ... }
|
||||
findBookingByUserId(userId: string) { ... }
|
||||
createBooking(data: BookingCreateInput) { ... }
|
||||
deleteBooking(id: string) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**2. Use `include` or similar keywords for methods that fetch relational data**
|
||||
|
||||
When a method retrieves additional related entities, make this explicit in the method name using keywords like `include`, `with`, or `andRelations`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Clear indication of included relations
|
||||
class EventTypeRepository {
|
||||
findById(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
findByIdIncludeHosts(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByIdIncludeHostsAndSchedule(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
schedule: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - Unclear what data is included
|
||||
class EventTypeRepository {
|
||||
findById(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
schedule: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByIdForReporting(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Keep methods generic and reusable - avoid use-case-specific names**
|
||||
|
||||
Repository methods should be general-purpose and describe what data they return, not how or where it's used. This promotes code reuse across different features.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Generic, reusable methods
|
||||
class BookingRepository {
|
||||
findByUserIdIncludeAttendees(userId: string) {
|
||||
return prisma.booking.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByDateRangeIncludeEventType(startDate: Date, endDate: Date) {
|
||||
return prisma.booking.findMany({
|
||||
where: {
|
||||
startTime: { gte: startDate },
|
||||
endTime: { lte: endDate },
|
||||
},
|
||||
include: {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - Use-case-specific method names
|
||||
class BookingRepository {
|
||||
findBookingsForReporting(userId: string) {
|
||||
return prisma.booking.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findBookingsForDashboard(startDate: Date, endDate: Date) {
|
||||
return prisma.booking.findMany({
|
||||
where: {
|
||||
startTime: { gte: startDate },
|
||||
endTime: { lte: endDate },
|
||||
},
|
||||
include: {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. No business logic in repositories**
|
||||
|
||||
Repositories should only handle data access. Business logic, validations, and complex transformations belong in the Service layer.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Repository only handles data access
|
||||
class BookingRepository {
|
||||
findByIdIncludeAttendees(id: string) {
|
||||
return prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus(id: string, status: BookingStatus) {
|
||||
return prisma.booking.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class BookingService {
|
||||
async confirmBooking(bookingId: string) {
|
||||
const booking = await this.bookingRepository.findByIdIncludeAttendees(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
if (booking.status !== "PENDING") {
|
||||
throw new Error("Only pending bookings can be confirmed");
|
||||
}
|
||||
|
||||
// Business logic: send confirmation emails
|
||||
await this.emailService.sendConfirmationToAttendees(booking.attendees);
|
||||
|
||||
// Update status through repository
|
||||
return this.bookingRepository.updateStatus(bookingId, "CONFIRMED");
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - Business logic in repository
|
||||
class BookingRepository {
|
||||
async confirmBooking(bookingId: string) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
if (booking.status !== "PENDING") {
|
||||
throw new Error("Only pending bookings can be confirmed");
|
||||
}
|
||||
|
||||
// ❌ Business logic shouldn't be here
|
||||
await sendEmailToAttendees(booking.attendees);
|
||||
|
||||
return prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Summary
|
||||
|
||||
- Method names should be concise: `findById` not `findBookingById`
|
||||
- Use `include`/`with` keywords when fetching relations: `findByIdIncludeHosts`
|
||||
- Keep methods generic and reusable: `findByUserIdIncludeAttendees` not `findBookingsForReporting`
|
||||
- No business logic in repositories - that belongs in Services
|
||||
|
||||
## When using Day.js
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
This PR adds ability to display bookings of a user event for calendar view atom
|
||||
+4
-5
@@ -10,7 +10,6 @@
|
||||
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
/deploy/**/* @calcom/Foundation
|
||||
/infra/**/* @calcom/Foundation
|
||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
|
||||
@@ -25,10 +24,10 @@
|
||||
/packages/embeds/**/* @calcom/Foundation
|
||||
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
|
||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation
|
||||
/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/features/schedules/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
|
||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.formatOnSave": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
|
||||
Vendored
Vendored
@@ -1,10 +1,10 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -2494,7 +2494,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3026,13 +3026,154 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type with max bookings count per booker", () => {
|
||||
it("should not allow booking more than maximumActiveBookings per attendee", async () => {
|
||||
const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
slug: `max-bookings-count-per-booker-${randomString(10)}`,
|
||||
length: 60,
|
||||
title: "Event Type with max bookings count per booker",
|
||||
maxActiveBookingsPerBooker: 1,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 13, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response2.body.error.message).toBe(
|
||||
"Attendee with this email can't book because the maximum number of active bookings has been reached."
|
||||
);
|
||||
|
||||
const body2: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 13, 12, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
|
||||
attendee: {
|
||||
name: "bob",
|
||||
email: "bob@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response3 = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body2)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody2: CreateBookingOutput_2024_08_13 = response3.body;
|
||||
expect(responseBody2.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody2.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody2.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody2.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody2.data;
|
||||
expect(data.id).toBeDefined();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not allow booking more than maximumActiveBookings per attendee and offer rescheduling", async () => {
|
||||
const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
slug: `max-bookings-count-per-booker-with-reschedule-${randomString(10)}`,
|
||||
length: 60,
|
||||
title: "Event Type with max bookings count per booker with reschedule",
|
||||
maxActiveBookingsPerBooker: 1,
|
||||
maxActiveBookingPerBookerOfferReschedule: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 13, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
|
||||
attendee: {
|
||||
name: "charlie",
|
||||
email: "charlie@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response2.body.error.message).toBe(
|
||||
`Attendee with this email can't book because the maximum number of active bookings has been reached. You can reschedule your existing booking (${responseBody.data.uid}) to a new timeslot instead.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
function responseDataIsRecurranceBooking(data: unknown): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
data &&
|
||||
"id" in data &&
|
||||
"recurringBookingUid" in data
|
||||
@@ -3048,11 +3189,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
|
||||
}
|
||||
|
||||
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
|
||||
function responseDataIsRecurringBooking(data: unknown): data is RecurringBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
} else if (error.message === "hosts_unavailable_for_booking") {
|
||||
throw new BadRequestException(hostsUnavaile);
|
||||
} else if (error.message === "booker_limit_exceeded_error") {
|
||||
throw new BadRequestException(
|
||||
"Attendee with this email can't book because the maximum number of active bookings has been reached."
|
||||
);
|
||||
} else if (error.message === "booker_limit_exceeded_error_reschedule") {
|
||||
const errorData =
|
||||
"data" in error ? (error.data as { rescheduleUid: string }) : { rescheduleUid: undefined };
|
||||
let message =
|
||||
"Attendee with this email can't book because the maximum number of active bookings has been reached.";
|
||||
if (errorData?.rescheduleUid) {
|
||||
message += ` You can reschedule your existing booking (${errorData.rescheduleUid}) to a new timeslot instead.`;
|
||||
}
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
||||
+182
-9
@@ -31,16 +31,18 @@ import {
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import type {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
SplitNameDefaultFieldOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
import {
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
type GuestsDefaultFieldOutput_2024_06_14,
|
||||
type NameDefaultFieldInput_2024_06_14,
|
||||
type NotesDefaultFieldInput_2024_06_14,
|
||||
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||
type UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
|
||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||
@@ -1442,6 +1444,177 @@ describe("Event types Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe("bookerActiveBookingsLimit", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not create an event type with bookerActiveBookingsLimit and recurrence", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit",
|
||||
slug: "coding-class-booker-active-bookings-limit",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with bookerActiveBookingsLimit disabled:false", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit disabled:false",
|
||||
slug: "coding-class-booker-active-bookings-limit-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
// note(Lauris): disabled false means that it is enabled so it should have maximumActiveBookings and / or offerReschedule provided
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message.includes(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
let eventTypeWithBookerActiveBookingsLimitId: number;
|
||||
|
||||
it("should create an event type with bookerActiveBookingsLimit", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit",
|
||||
slug: "coding-class-booker-active-bookings-limit",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with bookerActiveBookingsLimit", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithBookerActiveBookingsLimitId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
expect(updatedEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with bookerActiveBookingsLimit and recurrence disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit and recurrence disabled",
|
||||
slug: "coding-class-booker-active-bookings-limit-recurrence-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
recurrence: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with recurrence and bookerActiveBookingsLimit disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit disabled and recurrence",
|
||||
slug: "coding-class-booker-active-bookings-limit-disabled-and-recurrence",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+65
-25
@@ -28,7 +28,11 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||
import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
validateCustomEventName,
|
||||
EventTypeMetaDataSchema,
|
||||
EventTypeMetadata,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
DestinationCalendar_2024_06_14,
|
||||
@@ -67,11 +71,13 @@ export class InputEventTypesService_2024_06_14 {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.validateInputUseDestinationCalendarEmail(user.id);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -93,11 +99,13 @@ export class InputEventTypesService_2024_06_14 {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.validateInputUseDestinationCalendarEmail(user.id);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -119,6 +127,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
@@ -130,6 +139,17 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
};
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
@@ -140,12 +160,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
metadata: {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
},
|
||||
metadata,
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||
@@ -154,11 +169,27 @@ export class InputEventTypesService_2024_06_14 {
|
||||
...this.transformInputSeatOptions(seats),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
...maxActiveBookingsPerBooker,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
transformInputBookerActiveBookingsLimit(
|
||||
bookerActiveBookingsLimit: CreateEventTypeInput_2024_06_14["bookerActiveBookingsLimit"]
|
||||
) {
|
||||
if (!bookerActiveBookingsLimit || bookerActiveBookingsLimit.disabled) {
|
||||
return {
|
||||
maxActiveBookingsPerBooker: null,
|
||||
maxActiveBookingPerBookerOfferReschedule: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxActiveBookingsPerBooker: bookerActiveBookingsLimit?.maximumActiveBookings,
|
||||
maxActiveBookingPerBookerOfferReschedule: bookerActiveBookingsLimit?.offerReschedule,
|
||||
};
|
||||
}
|
||||
|
||||
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
|
||||
const {
|
||||
lengthInMinutes,
|
||||
@@ -176,10 +207,11 @@ export class InputEventTypesService_2024_06_14 {
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
const metadataTransformed = !!eventTypeDb?.metadata
|
||||
const metadataTransformed = eventTypeDb?.metadata
|
||||
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
|
||||
: {};
|
||||
|
||||
@@ -190,6 +222,18 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
...metadataTransformed,
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
};
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
@@ -202,13 +246,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
metadata: {
|
||||
...metadataTransformed,
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
},
|
||||
metadata,
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
@@ -217,6 +255,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
...this.transformInputSeatOptions(seats),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
...maxActiveBookingsPerBooker,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
@@ -319,7 +358,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
|
||||
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
|
||||
return !!res ? res : {};
|
||||
return res ? res : {};
|
||||
}
|
||||
|
||||
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
|
||||
@@ -369,7 +408,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
|
||||
}
|
||||
|
||||
const seatsPerTimeSlotFinal = !!seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
||||
const seatsPerTimeSlotFinal = seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
||||
const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0;
|
||||
|
||||
const locationsFinal = locations !== undefined ? locations : locationsDb;
|
||||
@@ -394,6 +433,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformLocations(locations: any) {
|
||||
if (!locations) return [];
|
||||
|
||||
|
||||
+28
@@ -92,6 +92,8 @@ type Input = Pick<
|
||||
| "calVideoSettings"
|
||||
| "hidden"
|
||||
| "bookingRequiresAuthentication"
|
||||
| "maxActiveBookingsPerBooker"
|
||||
| "maxActiveBookingPerBookerOfferReschedule"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -165,6 +167,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
periodEndDate: databaseEventType.periodEndDate,
|
||||
} as TransformFutureBookingsLimitSchema_2024_06_14);
|
||||
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
|
||||
const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -210,9 +213,29 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
calVideoSettings,
|
||||
hidden,
|
||||
bookingRequiresAuthentication,
|
||||
bookerActiveBookingsLimit,
|
||||
};
|
||||
}
|
||||
|
||||
transformBookerActiveBookingsLimit(databaseEventType: Input) {
|
||||
const noMaxActiveBookingsPerBooker =
|
||||
!databaseEventType.maxActiveBookingsPerBooker && databaseEventType.maxActiveBookingsPerBooker !== 0;
|
||||
const noMaxActiveBookingPerBookerOfferReschedule =
|
||||
!databaseEventType.maxActiveBookingPerBookerOfferReschedule;
|
||||
|
||||
if (noMaxActiveBookingsPerBooker && noMaxActiveBookingPerBookerOfferReschedule) {
|
||||
return {
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
maximumActiveBookings: databaseEventType.maxActiveBookingsPerBooker ?? undefined,
|
||||
offerReschedule: databaseEventType.maxActiveBookingPerBookerOfferReschedule ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformLocations(locationDb: any) {
|
||||
if (!locationDb) return [];
|
||||
|
||||
@@ -239,6 +262,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformBookingFields(bookingFields: any) {
|
||||
if (!bookingFields) return [];
|
||||
|
||||
@@ -273,6 +297,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return this.transformBookingFields(defaultBookingFields);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
@@ -280,6 +305,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return transformRecurrenceInternalToApi(recurringEventParsed);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformMetadata(metadata: any) {
|
||||
if (!metadata) return {};
|
||||
return EventTypeMetaDataSchema.parse(metadata);
|
||||
@@ -301,6 +327,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformIntervalLimits(bookingLimits: any) {
|
||||
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
|
||||
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
|
||||
@@ -327,6 +354,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformEventTypeColor(eventTypeColor: any) {
|
||||
if (!eventTypeColor) return undefined;
|
||||
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
|
||||
|
||||
+2
@@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit<
|
||||
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
|
||||
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
|
||||
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;
|
||||
maxActiveBookingsPerBooker?: number;
|
||||
maxActiveBookingPerBookerOfferReschedule?: boolean;
|
||||
eventTypeColor?: ReturnType<typeof transformEventColorsApiToInternal>;
|
||||
useEventTypeDestinationCalendarEmail?: boolean;
|
||||
} & Partial<
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
Version,
|
||||
@@ -21,8 +22,16 @@ import {
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type {
|
||||
GetAvailabilityListHandlerReturn,
|
||||
DuplicateScheduleHandlerReturn,
|
||||
} from "@calcom/platform-libraries/schedules";
|
||||
import { getAvailabilityListHandler, duplicateScheduleHandler } from "@calcom/platform-libraries/schedules";
|
||||
import type { CreateScheduleHandlerReturn, CreateScheduleSchema } from "@calcom/platform-libraries/schedules";
|
||||
import { createScheduleHandler } from "@calcom/platform-libraries/schedules";
|
||||
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
|
||||
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
|
||||
|
||||
@@ -63,6 +72,22 @@ export class AtomsSchedulesController {
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/schedules/all")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getAllUserSchedules(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<Awaited<GetAvailabilityListHandlerReturn>>> {
|
||||
const userSchedules = await getAvailabilityListHandler({ ctx: { user } });
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: userSchedules,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("schedules/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -83,4 +108,36 @@ export class AtomsSchedulesController {
|
||||
data: updatedSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("schedules/create")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Create atom schedule" })
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: z.infer<typeof CreateScheduleSchema>
|
||||
): Promise<ApiResponse<CreateScheduleHandlerReturn>> {
|
||||
const createdSchedule = await createScheduleHandler({ input: bodySchedule, ctx: { user } });
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: createdSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("schedules/:scheduleId/duplicate")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Duplicate existing schedule" })
|
||||
async duplicateExistingSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId", ParseIntPipe) scheduleId: number
|
||||
): Promise<ApiResponse<Awaited<DuplicateScheduleHandlerReturn>>> {
|
||||
const duplicatedSchedule = await duplicateScheduleHandler({ ctx: { user }, input: { scheduleId } });
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: duplicatedSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -355,6 +355,11 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
darkThemeHex: "#292929",
|
||||
lightThemeHex: "#fafafa",
|
||||
},
|
||||
emailSettings: {
|
||||
disableEmailsToAttendees: true,
|
||||
disableEmailsToHosts: true,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -385,6 +390,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
expect(data.emailSettings).toEqual(body.emailSettings);
|
||||
collectiveEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
@@ -600,6 +606,11 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
hosts: newHosts,
|
||||
successRedirectUrl: "https://new-url-success.com",
|
||||
emailSettings: {
|
||||
disableEmailsToAttendees: false,
|
||||
disableEmailsToHosts: false,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -614,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com");
|
||||
expect(eventType.title).toEqual(collectiveEventType.title);
|
||||
expect(eventType.hosts.length).toEqual(1);
|
||||
expect(eventType.emailSettings).toEqual(body.emailSettings);
|
||||
expect(eventType.rescheduleWithSameRoundRobinHost).toEqual(body.rescheduleWithSameRoundRobinHost);
|
||||
evaluateHost(eventType.hosts[0], newHosts[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
HostPriority,
|
||||
EmailSettings_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type TransformedCreateTeamEventTypeInput = Awaited<
|
||||
@@ -49,14 +51,16 @@ export class InputOrganizationsEventTypesService {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
userId,
|
||||
transformedBody.destinationCalendar
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -83,14 +87,16 @@ export class InputOrganizationsEventTypesService {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
userId,
|
||||
transformedBody.destinationCalendar
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -110,7 +116,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
||||
|
||||
@@ -123,11 +129,15 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
||||
|
||||
const metadata =
|
||||
let metadata =
|
||||
rest.schedulingType === "MANAGED"
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
}
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
@@ -145,12 +155,42 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
private addEmailSettingsToMetadata(
|
||||
emailSettings: EmailSettings_2024_06_14,
|
||||
metadata: NonNullable<EventTypeMetadata>
|
||||
) {
|
||||
if (
|
||||
emailSettings?.disableEmailsToAttendees === undefined &&
|
||||
emailSettings?.disableEmailsToHosts === undefined
|
||||
) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const clonedMetadata = structuredClone(metadata);
|
||||
|
||||
if (!clonedMetadata.disableStandardEmails) {
|
||||
clonedMetadata.disableStandardEmails = {};
|
||||
}
|
||||
if (!clonedMetadata.disableStandardEmails.all) {
|
||||
clonedMetadata.disableStandardEmails.all = {};
|
||||
}
|
||||
|
||||
if (emailSettings?.disableEmailsToAttendees !== undefined) {
|
||||
clonedMetadata.disableStandardEmails.all.attendee = emailSettings.disableEmailsToAttendees;
|
||||
}
|
||||
if (emailSettings?.disableEmailsToHosts !== undefined) {
|
||||
clonedMetadata.disableStandardEmails.all.host = emailSettings.disableEmailsToHosts;
|
||||
}
|
||||
|
||||
return clonedMetadata;
|
||||
}
|
||||
|
||||
async transformInputUpdateTeamEventType(
|
||||
eventTypeId: number,
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
|
||||
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||
@@ -164,6 +204,12 @@ export class InputOrganizationsEventTypesService {
|
||||
? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId)
|
||||
: undefined;
|
||||
|
||||
let metadata = eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
}
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
@@ -175,6 +221,7 @@ export class InputOrganizationsEventTypesService {
|
||||
assignAllTeamMembers,
|
||||
children,
|
||||
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return teamEventType;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
|
||||
import type { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
|
||||
import type {
|
||||
Team,
|
||||
@@ -84,6 +85,9 @@ type Input = Pick<
|
||||
| "calVideoSettings"
|
||||
| "hidden"
|
||||
| "bookingRequiresAuthentication"
|
||||
| "rescheduleWithSameRoundRobinHost"
|
||||
| "maxActiveBookingPerBookerOfferReschedule"
|
||||
| "maxActiveBookingsPerBooker"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -95,7 +99,12 @@ export class OutputOrganizationsEventTypesService {
|
||||
) {}
|
||||
|
||||
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
|
||||
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
|
||||
const metadata = this.outputEventTypesService.transformMetadata(databaseEventType.metadata);
|
||||
|
||||
const emailSettings = this.transformEmailSettings(metadata);
|
||||
|
||||
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
|
||||
databaseEventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
|
||||
0,
|
||||
@@ -117,6 +126,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
? this.getResponseSchedulingType(databaseEventType.schedulingType)
|
||||
: databaseEventType.schedulingType,
|
||||
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
|
||||
emailSettings,
|
||||
team: {
|
||||
id: teamId,
|
||||
name: databaseEventType?.team?.name,
|
||||
@@ -128,6 +138,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
darkBrandColor: databaseEventType?.team?.darkBrandColor,
|
||||
theme: databaseEventType?.team?.theme,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,6 +205,23 @@ export class OutputOrganizationsEventTypesService {
|
||||
|
||||
return transformedHosts;
|
||||
}
|
||||
|
||||
private transformEmailSettings(metadata: EventTypeMetadata) {
|
||||
if (!metadata?.disableStandardEmails?.all) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { attendee, host } = metadata.disableStandardEmails.all;
|
||||
|
||||
if (attendee !== undefined || host !== undefined) {
|
||||
return {
|
||||
disableEmailsToAttendees: attendee ?? false,
|
||||
disableEmailsToHosts: host ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority: number): keyof typeof HostPriority {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject, IsNumber } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
@@ -17,6 +17,8 @@ export class CreateManagedUserInput {
|
||||
@ApiProperty({ example: "Alice Smith", description: "Managed user's name is used in emails" })
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" })
|
||||
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
@@ -17,6 +18,8 @@ export class UpdateManagedUserInput {
|
||||
@ApiPropertyOptional()
|
||||
name?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@IsIn([12, 24])
|
||||
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
|
||||
@@ -56,15 +57,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
// This is because the data is cached and as a result the data is converted to a string
|
||||
const availabilities = {
|
||||
...cachedAvailabilities,
|
||||
schedules: cachedAvailabilities.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
availability: schedule.availability.map((avail) => ({
|
||||
...avail,
|
||||
startTime: new Date(avail.startTime),
|
||||
endTime: new Date(avail.endTime),
|
||||
date: avail.date ? new Date(avail.date) : null,
|
||||
})),
|
||||
})),
|
||||
schedules: cachedAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)),
|
||||
};
|
||||
|
||||
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
+9
-2
@@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "privacy",
|
||||
name: "privacy_and_security",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
|
||||
@@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
|
||||
const organizationAdminKeys = [
|
||||
"privacy",
|
||||
"privacy_and_security",
|
||||
"OAuth Clients",
|
||||
"SSO",
|
||||
"directory_sync",
|
||||
"delegation_credential",
|
||||
];
|
||||
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
|
||||
@@ -53,16 +53,23 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
|
||||
const [features, rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getTeamFeatures(orgId),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
const features = await getTeamFeatures(orgId);
|
||||
|
||||
if (features) {
|
||||
teamFeatures = {
|
||||
[orgId]: features,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if PBAC feature is enabled
|
||||
const isPbacEnabled = features?.pbac === true;
|
||||
|
||||
if (isPbacEnabled) {
|
||||
// Only fetch and apply PBAC permissions if the feature is enabled
|
||||
const [rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
|
||||
// Check if user has permission to read roles
|
||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||
@@ -71,7 +78,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
|
||||
} else {
|
||||
canViewRoles = false;
|
||||
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
|
||||
canViewRoles = features ? isOrgAdminOrOwner : false;
|
||||
canViewOrganizationBilling = isOrgAdminOrOwner;
|
||||
canUpdateOrganization = isOrgAdminOrOwner;
|
||||
}
|
||||
|
||||
+22
-5
@@ -11,7 +11,7 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("privacy"),
|
||||
(t) => t("privacy_and_security"),
|
||||
(t) => t("privacy_organization_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -19,9 +19,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await validateUserHasOrg();
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
@@ -42,13 +41,31 @@ const Page = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const watchlistPermissions = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Watchlist,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
|
||||
<PrivacyView permissions={{ canRead, canEdit }} />
|
||||
<SettingsHeader title={t("privacy_and_security")} description={t("privacy_organization_description")}>
|
||||
<PrivacyView permissions={{ canRead, canEdit }} watchlistPermissions={watchlistPermissions} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("resume_onboarding"),
|
||||
(t) => t("resume_onboarding_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/new/resume"
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<ResumeOnboardingPage />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -6,11 +6,11 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delega
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -57,6 +58,9 @@ async function handler(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (checkoutSessionSubscription) {
|
||||
const { subscriptionStart } =
|
||||
StripeBillingService.extractSubscriptionDates(checkoutSessionSubscription);
|
||||
|
||||
const internalBillingService = new InternalTeamBilling(finalizedTeam);
|
||||
await internalBillingService.saveTeamBilling({
|
||||
teamId: finalizedTeam.id,
|
||||
@@ -66,6 +70,7 @@ async function handler(request: NextRequest) {
|
||||
// TODO: Implement true subscription status when webhook events are implemented
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planName: Plan.TEAM,
|
||||
subscriptionStart,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -87,6 +88,7 @@ async function getHandler(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (checkoutSession && subscription) {
|
||||
const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(subscription);
|
||||
const internalBillingService = new InternalTeamBilling(team);
|
||||
await internalBillingService.saveTeamBilling({
|
||||
teamId: team.id,
|
||||
@@ -96,6 +98,7 @@ async function getHandler(req: NextRequest) {
|
||||
// TODO: Implement true subscription status when webhook events are implemented
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planName: Plan.TEAM,
|
||||
subscriptionStart,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -160,13 +160,21 @@ export const AppPage = ({
|
||||
function refactorMeWithoutEffect() {
|
||||
const data = appDbQuery.data;
|
||||
|
||||
const credentialsCount = data?.credentials.length || 0;
|
||||
setExistingCredentials(data?.credentials || []);
|
||||
const credentials = data?.credentials || [];
|
||||
setExistingCredentials(credentials);
|
||||
|
||||
const hasPersonalInstall = credentials.some((c) => !!c.userId && !c.teamId);
|
||||
const installedTeamIds = new Set<number>();
|
||||
for (const cred of credentials) {
|
||||
if (cred.teamId) installedTeamIds.add(cred.teamId);
|
||||
}
|
||||
|
||||
const totalInstalledTargets = (hasPersonalInstall ? 1 : 0) + installedTeamIds.size;
|
||||
|
||||
const appInstalledForAllTargets =
|
||||
availableForTeams && data?.userAdminTeams && data.userAdminTeams.length > 0
|
||||
? credentialsCount >= data.userAdminTeams.length
|
||||
: credentialsCount > 0;
|
||||
? totalInstalledTargets >= data.userAdminTeams.length + 1
|
||||
: credentials.length > 0;
|
||||
setAppInstalledForAllTargets(appInstalledForAllTargets);
|
||||
},
|
||||
[appDbQuery.data, availableForTeams]
|
||||
@@ -244,7 +252,14 @@ export const AppPage = ({
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return <InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />;
|
||||
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
credentials={availableForTeams ? undefined : appDbQuery.data?.credentials}
|
||||
paid={paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
|
||||
@@ -83,9 +83,12 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
props: {
|
||||
isEmbed,
|
||||
profile: {
|
||||
theme: form.user.theme,
|
||||
brandColor: form.user.brandColor,
|
||||
darkBrandColor: form.user.darkBrandColor,
|
||||
theme: formWithUserProfile.user.profile?.organization?.theme ?? formWithUserProfile.user.theme,
|
||||
brandColor:
|
||||
formWithUserProfile.user.profile?.organization?.brandColor ?? formWithUserProfile.user.brandColor,
|
||||
darkBrandColor:
|
||||
formWithUserProfile.user.profile?.organization?.darkBrandColor ??
|
||||
formWithUserProfile.user.darkBrandColor,
|
||||
},
|
||||
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
|
||||
},
|
||||
|
||||
+15
-3
@@ -49,6 +49,13 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
profile: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamId: true,
|
||||
@@ -71,9 +78,15 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
parent: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
createdByOAuthClientId: true,
|
||||
@@ -116,12 +129,11 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
}
|
||||
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
const { profile, ...restEventType } = eventType;
|
||||
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
|
||||
const isOrgTeamEvent = !!eventType?.team && !!eventType.profile?.organizationId;
|
||||
|
||||
return {
|
||||
isDynamic: false,
|
||||
...restEventType,
|
||||
...eventType,
|
||||
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -8,12 +8,12 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
|
||||
import {
|
||||
shouldHideBrandingForTeamEvent,
|
||||
shouldHideBrandingForUserEvent,
|
||||
} from "@calcom/features/profile/lib/hideBranding";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization";
|
||||
import { organizationScenarios } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock("@calcom/prisma", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/ee/billing/stripe-billling-service", () => {
|
||||
vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => {
|
||||
return {
|
||||
StripeBillingService: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
|
||||
@@ -4,11 +4,11 @@ import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { buildEventUrlFromBooking } from "@calcom/features/bookings/lib/buildEventUrlFromBooking";
|
||||
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
|
||||
@@ -7,8 +7,9 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
@@ -30,7 +31,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
|
||||
@@ -133,6 +134,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
|
||||
const branding = getBrandingForEventType({
|
||||
eventType: {
|
||||
team: team.parent ?? team,
|
||||
users: [],
|
||||
profile: null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
useApiV2,
|
||||
@@ -153,6 +162,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
name,
|
||||
username: orgSlug ?? null,
|
||||
...branding,
|
||||
},
|
||||
title: eventData.title,
|
||||
users: eventHostsUserData,
|
||||
@@ -204,6 +214,9 @@ const getTeamWithEventsData = async (
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
@@ -214,6 +227,9 @@ const getTeamWithEventsData = async (
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
slug: meetingSlug,
|
||||
|
||||
@@ -141,6 +141,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
|
||||
<ul className="divide-subtle divide-y" data-testid="schedules" ref={animationParentRef}>
|
||||
{availabilities.schedules.map((schedule) => (
|
||||
<ScheduleListItem
|
||||
redirectUrl={`/availability/${schedule.id}`}
|
||||
displayOptions={{
|
||||
hour12: user?.timeFormat ? user.timeFormat === 12 : undefined,
|
||||
timeZone: user?.timeZone,
|
||||
|
||||
@@ -6,12 +6,13 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
|
||||
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
|
||||
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
@@ -115,7 +116,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
||||
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
|
||||
|
||||
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
|
||||
eventTypeRaw.users = eventTypeRaw.hosts?.length
|
||||
? eventTypeRaw.hosts.map((host) => host.user)
|
||||
: eventTypeRaw.users;
|
||||
|
||||
@@ -150,9 +151,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const profile = {
|
||||
name: eventType.team?.name || eventType.users[0]?.name || null,
|
||||
email: eventType.team ? null : eventType.users[0].email || null,
|
||||
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
|
||||
brandColor: eventType.team ? null : eventType.users[0].brandColor || null,
|
||||
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
|
||||
...getBrandingForEventType({ eventType: eventTypeRaw }),
|
||||
slug: eventType.team?.slug || eventType.users[0]?.username || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -24,9 +24,13 @@ import {
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks";
|
||||
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
|
||||
import {
|
||||
SMS_REMINDER_NUMBER_FIELD,
|
||||
SystemField,
|
||||
TITLE_FIELD,
|
||||
} from "@calcom/lib/bookings/SystemField";
|
||||
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
import { SMS_REMINDER_NUMBER_FIELD, SystemField, TITLE_FIELD } from "@calcom/lib/SystemField";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
@@ -694,7 +698,7 @@ export default function Success(props: PageProps) {
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p className="break-words">{bookingInfo.description}</p>
|
||||
<p className="whitespace-pre-line break-words">{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -767,7 +771,7 @@ export default function Success(props: PageProps) {
|
||||
<Fragment key={field.name}>
|
||||
<div
|
||||
className="text-emphasis mt-4 font-medium"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToSafeHTML(label),
|
||||
}}
|
||||
@@ -1080,7 +1084,7 @@ export default function Success(props: PageProps) {
|
||||
<span className="underline">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations">
|
||||
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations" rel="noreferrer">
|
||||
{t("resolve")}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -147,6 +147,9 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
|
||||
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
|
||||
!!user.receiveMonthlyDigestEmail
|
||||
);
|
||||
const [isRequireBookerEmailVerificationChecked, setIsRequireBookerEmailVerificationChecked] = useState(
|
||||
!!user.requiresBookerEmailVerification
|
||||
);
|
||||
|
||||
const watchedTzSchedules = formMethods.watch("travelSchedules");
|
||||
|
||||
@@ -353,6 +356,19 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
|
||||
}}
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("require_booker_email_verification")}
|
||||
description={t("require_booker_email_verification_description")}
|
||||
disabled={mutation.isPending}
|
||||
checked={isRequireBookerEmailVerificationChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsRequireBookerEmailVerificationChecked(checked);
|
||||
mutation.mutate({ requiresBookerEmailVerification: checked });
|
||||
}}
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
<TravelScheduleModal
|
||||
open={isTZScheduleOpen}
|
||||
onOpenChange={() => setIsTZScheduleOpen(false)}
|
||||
|
||||
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
<TextField
|
||||
key={field.id}
|
||||
{...register(`teams.${index}.name`)}
|
||||
data-testid={`team.${index}.name`}
|
||||
label=""
|
||||
addOnClassname="bg-transparent p-0 border-l-0"
|
||||
className={index > 0 ? "mb-2" : ""}
|
||||
|
||||
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setServerErrorMessage(t(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||
// Single mutation for all flows (billing, self-hosted, admin)
|
||||
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data.organization) {
|
||||
// Invalidate the organizations query to ensure fresh data on the next page
|
||||
reset({
|
||||
onboardingId: data.organizationOnboardingId,
|
||||
});
|
||||
|
||||
if (data.checkoutUrl) {
|
||||
// Billing enabled - redirect to Stripe
|
||||
window.location.href = data.checkoutUrl;
|
||||
} else if (data.organizationId) {
|
||||
// Self-hosted - org already created, redirect to organizations
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
} else {
|
||||
// Unexpected state
|
||||
setServerErrorMessage("Unexpected response from server");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||
|
||||
return {
|
||||
mutation: mutationToUse,
|
||||
mutate: mutationToUse.mutate,
|
||||
isPending: mutationToUse.isPending,
|
||||
mutation: intentToCreateOrgMutation,
|
||||
mutate: intentToCreateOrgMutation.mutate,
|
||||
isPending: intentToCreateOrgMutation.isPending,
|
||||
errorMessage: serverErrorMessage,
|
||||
};
|
||||
};
|
||||
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
|
||||
invitedMembers,
|
||||
logo,
|
||||
bio,
|
||||
onboardingId,
|
||||
name,
|
||||
slug,
|
||||
billingPeriod,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
brandColor,
|
||||
bannerUrl,
|
||||
} = useOnboardingStore();
|
||||
const orgCreation = useOrgCreation();
|
||||
|
||||
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
placeholder="colleague@company.com"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" StartIcon="plus" color="secondary">
|
||||
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<Button
|
||||
data-testid="publish-button"
|
||||
onClick={() => {
|
||||
if (!onboardingId) {
|
||||
console.error("Org owner email and onboardingId are required", {
|
||||
orgOwnerEmail,
|
||||
onboardingId,
|
||||
});
|
||||
// Submit ALL data to intentToCreateOrg
|
||||
if (!name || !slug || !orgOwnerEmail) {
|
||||
console.error("Required fields missing", { name, slug, orgOwnerEmail });
|
||||
showToast(t("required_fields_missing"), "error");
|
||||
return;
|
||||
}
|
||||
orgCreation.mutation.mutate({
|
||||
|
||||
orgCreation.mutate({
|
||||
name,
|
||||
slug,
|
||||
orgOwnerEmail,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
billingPeriod,
|
||||
creationSource: "WEBAPP" as const,
|
||||
logo,
|
||||
bio,
|
||||
brandColor,
|
||||
bannerUrl,
|
||||
teams,
|
||||
invitedMembers,
|
||||
onboardingId,
|
||||
});
|
||||
}}
|
||||
loading={orgCreation.isPending}>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const PaymentStatusView = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const paymentStatus = searchParams?.get("paymentStatus");
|
||||
const paymentError = searchParams?.get("error");
|
||||
const { useOnboardingStore } = useOnboarding({
|
||||
step: "status",
|
||||
});
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
|
||||
const { name } = useOnboardingStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={5}>
|
||||
{children}
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeOnboardingView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const onboardingIdParam = searchParams?.get("onboardingId");
|
||||
|
||||
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onboardingIdParam) {
|
||||
router.push("/settings/organizations/new");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbOnboarding.isComplete) {
|
||||
router.push("/settings/organizations");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load onboarding data into store
|
||||
reset({
|
||||
onboardingId: dbOnboarding.id,
|
||||
name: dbOnboarding.name,
|
||||
slug: dbOnboarding.slug,
|
||||
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
|
||||
billingPeriod: dbOnboarding.billingPeriod,
|
||||
seats: dbOnboarding.seats,
|
||||
pricePerSeat: dbOnboarding.pricePerSeat,
|
||||
logo: dbOnboarding.logo,
|
||||
bio: dbOnboarding.bio,
|
||||
brandColor: dbOnboarding.brandColor,
|
||||
bannerUrl: dbOnboarding.bannerUrl,
|
||||
});
|
||||
|
||||
// Redirect to next step (About page)
|
||||
router.push("/settings/organizations/new/about");
|
||||
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
|
||||
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return (
|
||||
<SkeletonContainer className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-4 w-3/4" />
|
||||
<SkeletonText className="h-4 w-1/2" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onboardingIdParam) {
|
||||
return (
|
||||
<Alert
|
||||
data-testid="error"
|
||||
severity="error"
|
||||
title={t("error")}
|
||||
message={t("no_onboarding_id_provided")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbOnboarding) {
|
||||
return (
|
||||
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
|
||||
);
|
||||
}
|
||||
|
||||
if (dbOnboarding.isComplete) {
|
||||
return (
|
||||
<Alert
|
||||
data-testid="error"
|
||||
severity="info"
|
||||
title={t("onboarding_already_complete")}
|
||||
message={t("onboarding_already_complete_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state while redirecting
|
||||
return (
|
||||
<SkeletonContainer className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-4 w-3/4" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeOnboardingView;
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { DataTableWrapper, DataTableToolbar, useDataTable } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { BlocklistEntryDetailsSheet } from "./components/blocklist-entry-details-sheet";
|
||||
import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal";
|
||||
|
||||
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
|
||||
|
||||
interface BlocklistTableProps {
|
||||
permissions?: {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function BlocklistTable({ permissions }: BlocklistTableProps) {
|
||||
const { t } = useLocale();
|
||||
const { limit, offset, searchTerm } = useDataTable();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDetailsSheet, setShowDetailsSheet] = useState(false);
|
||||
const [selectedEntry, setSelectedEntry] = useState<BlocklistEntry | null>(null);
|
||||
const [entryToDelete, setEntryToDelete] = useState<BlocklistEntry | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const { data, isPending } = trpc.viewer.organizations.listWatchlistEntries.useQuery(
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const deleteWatchlistEntry = trpc.viewer.organizations.deleteWatchlistEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.organizations.listWatchlistEntries.invalidate();
|
||||
showToast(t("blocklist_entry_deleted"), "success");
|
||||
setShowDeleteDialog(false);
|
||||
setEntryToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (entry: BlocklistEntry) => {
|
||||
setEntryToDelete(entry);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (entryToDelete) {
|
||||
deleteWatchlistEntry.mutate({ id: entryToDelete.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (entry: BlocklistEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
setShowDetailsSheet(true);
|
||||
};
|
||||
|
||||
const totalRowCount = data?.meta?.totalRowCount ?? 0;
|
||||
const flatData = useMemo<BlocklistEntry[]>(() => data?.rows ?? [], [data]);
|
||||
|
||||
const columns = useMemo<ColumnDef<BlocklistEntry>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "value",
|
||||
header: t("value"),
|
||||
accessorKey: "value",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => <span className="text-emphasis">{row.original.value}</span>,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: t("type"),
|
||||
accessorKey: "type",
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="blue">{row.original.type === "EMAIL" ? t("email") : t("domain")}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdBy",
|
||||
header: t("blocked_by"),
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const audit = row.original.audits?.[0] as
|
||||
| { changedByUserId: number | null }
|
||||
| {
|
||||
changedByUser?: { id: number; email: string; name: string | null } | undefined;
|
||||
changedByUserId: number | null;
|
||||
}
|
||||
| undefined;
|
||||
const email =
|
||||
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
|
||||
return <span className="text-default">{email ?? "—"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
size: 120,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => {
|
||||
const entry = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<ButtonGroup combined containerProps={{ className: "border-default" }}>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="eye"
|
||||
onClick={() => handleViewDetails(entry)}
|
||||
tooltip={t("view")}
|
||||
/>
|
||||
{permissions?.canDelete && (
|
||||
<Button
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
onClick={() => handleDelete(entry)}
|
||||
tooltip={t("delete")}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[t, permissions?.canDelete]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(totalRowCount / limit),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableWrapper
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
variant="default"
|
||||
paginationMode="standard"
|
||||
totalRowCount={totalRowCount}>
|
||||
<div className="flex items-center justify-between">
|
||||
<DataTableToolbar.SearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions?.canCreate && (
|
||||
<Button color="primary" StartIcon="plus" onClick={() => setShowCreateModal(true)}>
|
||||
{t("create_block_entry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataTableWrapper>
|
||||
|
||||
<CreateBlocklistEntryModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
||||
|
||||
<BlocklistEntryDetailsSheet
|
||||
entry={selectedEntry}
|
||||
isOpen={showDetailsSheet}
|
||||
onClose={() => {
|
||||
setShowDetailsSheet(false);
|
||||
setSelectedEntry(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_blocklist_entry")}
|
||||
confirmBtnText={t("delete")}
|
||||
onConfirm={confirmDelete}>
|
||||
{t("delete_blocklist_entry_confirmation", { value: entryToDelete?.value })}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
|
||||
|
||||
interface BlocklistEntryDetailsSheetProps {
|
||||
entry: BlocklistEntry | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BlocklistEntryDetailsSheet({ entry, isOpen, onClose }: BlocklistEntryDetailsSheetProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery(
|
||||
{ id: entry?.id ?? "" },
|
||||
{
|
||||
enabled: !!entry?.id && isOpen,
|
||||
}
|
||||
);
|
||||
|
||||
const getActionVariant = (action: string) => {
|
||||
switch (action) {
|
||||
case "BLOCK":
|
||||
return "red";
|
||||
case "REPORT":
|
||||
return "orange";
|
||||
case "ALERT":
|
||||
return "blue";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("blocklist_entry_details")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
<SkeletonText className="h-20 w-full" />
|
||||
</div>
|
||||
) : data?.entry ? (
|
||||
<div className="mt-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-default mb-1 block text-sm font-medium">{t("type")}</label>
|
||||
<Badge variant="blue" size="lg">
|
||||
{data.entry.type === "EMAIL" ? t("email") : t("domain")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-default mb-1 block text-sm font-medium">{t("value")}</label>
|
||||
<p className="text-emphasis text-base">{data.entry.value}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-default mb-1 block text-sm font-medium">{t("action")}</label>
|
||||
<Badge variant={getActionVariant(data.entry.action)} size="lg">
|
||||
{t(data.entry.action.toLowerCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-default mb-1 block text-sm font-medium">{t("description")}</label>
|
||||
<p className="text-default">{data.entry.description || t("no_description_provided")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-default mb-1 block text-sm font-medium">{t("created_by")}</label>
|
||||
<p className="text-default">{data.auditHistory[0]?.changedByUser?.email || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-subtle my-6 border-t" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-emphasis mb-4 font-semibold">{t("audit_history")}</h3>
|
||||
{data.auditHistory.length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{data.auditHistory.map((audit, index) => (
|
||||
<div
|
||||
key={audit.id}
|
||||
className={`py-3 ${
|
||||
index !== data.auditHistory.length - 1 ? "border-subtle border-b" : ""
|
||||
}`}>
|
||||
<div className="mb-1 flex items-start justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted text-xs">{t("by")}:</span>
|
||||
<span className="text-default text-sm">{audit.changedByUser?.email || "—"}</span>
|
||||
</div>
|
||||
<span className="text-muted text-xs">
|
||||
{format(new Date(audit.changedAt), "MMM d, h:mm a")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted text-xs">{t("added")}:</span>
|
||||
<span className="text-emphasis text-sm">{audit.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted text-sm">{t("no_audit_history")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted mt-8 text-center">{t("blocklist_entry_not_found")}</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WatchlistType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { Input, Select, Label, TextArea } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface CreateBlocklistEntryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
type: WatchlistType;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const watchType = watch("type");
|
||||
|
||||
const createWatchlistEntry = trpc.viewer.organizations.createWatchlistEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.organizations.listWatchlistEntries.invalidate();
|
||||
showToast(t("blocklist_entry_created"), "success");
|
||||
onClose();
|
||||
reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
createWatchlistEntry.mutate({
|
||||
type: data.type,
|
||||
value: data.value,
|
||||
description: data.description,
|
||||
});
|
||||
};
|
||||
|
||||
const validateValue = (value: string) => {
|
||||
if (!value) return t("required");
|
||||
|
||||
if (watchType === WatchlistType.EMAIL) {
|
||||
if (!emailRegex.test(value)) {
|
||||
return t("invalid_email_address");
|
||||
}
|
||||
} else if (watchType === WatchlistType.DOMAIN) {
|
||||
if (!domainRegex.test(value)) {
|
||||
return t("invalid_domain_format");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t("email"), value: WatchlistType.EMAIL },
|
||||
{ label: t("domain"), value: WatchlistType.DOMAIN },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogHeader title={t("create_block_entry")} />
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="type" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{t("what_would_you_like_to_block")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
rules={{ required: t("field_required") }}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
options={typeOptions}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
field.onChange(option.value);
|
||||
setValue("value", "");
|
||||
}
|
||||
}}
|
||||
value={typeOptions.find((opt) => opt.value === field.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.type && <p className="text-destructive mt-1 text-sm">{errors.type.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="value" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{watchType === WatchlistType.EMAIL ? t("email_address") : t("domain_name")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="value"
|
||||
control={control}
|
||||
rules={{
|
||||
required: t("field_required"),
|
||||
validate: validateValue,
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={watchType === WatchlistType.EMAIL ? "user@example.com" : "spammer.com"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.value && <p className="text-destructive mt-1 text-sm">{errors.value.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextArea {...field} placeholder={t("reason_for_adding_to_blocklist")} rows={3} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting || createWatchlistEntry.isPending}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting || createWatchlistEntry.isPending}
|
||||
disabled={isSubmitting || createWatchlistEntry.isPending}>
|
||||
{t("create_entry")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { TeamEventTypeForm } from "@calcom/features/ee/teams/components/TeamEventTypeForm";
|
||||
import { useCreateEventType } from "@calcom/features/eventtypes/hooks/useCreateEventType";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
@@ -60,7 +60,7 @@ vi.mock("@calcom/features/bookings/components/event-meta/Price", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/SystemField", () => {
|
||||
vi.mock("@calcom/lib/bookings/SystemField", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,14 @@ function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof
|
||||
theme: "dark",
|
||||
brandColor: "red",
|
||||
darkBrandColor: "black",
|
||||
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
|
||||
organization: {
|
||||
requestedSlug: "slug",
|
||||
slug: "slug",
|
||||
id: 1,
|
||||
brandColor: null,
|
||||
darkBrandColor: null,
|
||||
theme: null,
|
||||
},
|
||||
allowSEOIndexing: true,
|
||||
username: "john",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.7.12",
|
||||
"version": "5.8.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ErrorProps } from "next/error";
|
||||
import NextError from "next/error";
|
||||
import React from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
@@ -1,45 +1,120 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { createTeamEventType } from "./fixtures/users";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
async function testDuplicateAPICalls(
|
||||
page: Page,
|
||||
url: string,
|
||||
testDate?: Date
|
||||
): Promise<{ totalCalls: number; trpcCalls: number; apiV2Calls: number }> {
|
||||
const trpcCalls: string[] = [];
|
||||
const apiV2Calls: string[] = [];
|
||||
|
||||
if (testDate) {
|
||||
await page.clock.install({ time: testDate });
|
||||
}
|
||||
|
||||
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
|
||||
trpcCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||
apiV2Calls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(url);
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
return {
|
||||
totalCalls: trpcCalls.length + apiV2Calls.length,
|
||||
trpcCalls: trpcCalls.length,
|
||||
apiV2Calls: apiV2Calls.length,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Duplicate API Calls Prevention", () => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test("should detect when schedule endpoints are called multiple times for individual user events", async ({
|
||||
test("should detect when schedule endpoints are called multiple times for individual user events - beginning of month", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "30-min");
|
||||
const beginningOfMonth = new Date();
|
||||
beginningOfMonth.setDate(5);
|
||||
|
||||
const trpcCalls: string[] = [];
|
||||
const apiV2Calls: string[] = [];
|
||||
|
||||
// Intercept tRPC getSchedule calls - pattern matches /api/trpc/slots/getSchedule
|
||||
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
|
||||
trpcCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||
apiV2Calls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(`/${user.username}/${eventType?.slug}`);
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const totalCalls = trpcCalls.length + apiV2Calls.length;
|
||||
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
|
||||
page,
|
||||
`/${user.username}/${eventType?.slug}`,
|
||||
beginningOfMonth
|
||||
);
|
||||
|
||||
expect(totalCalls).toBeGreaterThan(0);
|
||||
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls.length).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should detect when schedule endpoints are called multiple times for team events", async ({
|
||||
test("should detect when schedule endpoints are called multiple times for individual user events - end of month", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "30-min");
|
||||
const endOfMonth = new Date();
|
||||
endOfMonth.setDate(20);
|
||||
|
||||
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
|
||||
page,
|
||||
`/${user.username}/${eventType?.slug}`,
|
||||
endOfMonth
|
||||
);
|
||||
|
||||
expect(totalCalls).toBeGreaterThan(0);
|
||||
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should detect when schedule endpoints are called multiple times for team events - beginning of month", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const teamOwner = await users.create(
|
||||
{ name: "Team Owner" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: [{ name: "teammate-1" }],
|
||||
}
|
||||
);
|
||||
const beginningOfMonth = new Date();
|
||||
beginningOfMonth.setDate(5);
|
||||
|
||||
const { team } = await teamOwner.getFirstTeamMembership();
|
||||
const teamEvent = await createTeamEventType(
|
||||
{ id: teamOwner.id },
|
||||
{ id: team.id },
|
||||
{ teamEventSlug: "team-event-test", teamEventTitle: "Team Event Test" }
|
||||
);
|
||||
|
||||
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
|
||||
page,
|
||||
`/team/${team.slug}/${teamEvent.slug}`,
|
||||
beginningOfMonth
|
||||
);
|
||||
|
||||
expect(totalCalls).toBeGreaterThan(0);
|
||||
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should detect when schedule endpoints are called multiple times for team events - end of month", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
@@ -57,33 +132,22 @@ test.describe("Duplicate API Calls Prevention", () => {
|
||||
{ id: team.id },
|
||||
{ teamEventSlug: "team-event-test", teamEventTitle: "Team Event Test" }
|
||||
);
|
||||
const endOfMonth = new Date();
|
||||
endOfMonth.setDate(20);
|
||||
|
||||
const trpcCalls: string[] = [];
|
||||
const apiV2Calls: string[] = [];
|
||||
|
||||
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
|
||||
trpcCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||
apiV2Calls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const totalCalls = trpcCalls.length + apiV2Calls.length;
|
||||
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
|
||||
page,
|
||||
`/team/${team.slug}/${teamEvent.slug}`,
|
||||
endOfMonth
|
||||
);
|
||||
|
||||
expect(totalCalls).toBeGreaterThan(0);
|
||||
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls.length).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should detect when schedule endpoints are called multiple times for organization team events", async ({
|
||||
test("should detect when schedule endpoints are called multiple times for organization team events - beginning of month", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
@@ -105,29 +169,55 @@ test.describe("Duplicate API Calls Prevention", () => {
|
||||
{ id: team.id },
|
||||
{ teamEventSlug: "org-team-event", teamEventTitle: "Org Team Event" }
|
||||
);
|
||||
const beginningOfMonth = new Date();
|
||||
beginningOfMonth.setDate(5);
|
||||
|
||||
const trpcCalls: string[] = [];
|
||||
const apiV2Calls: string[] = [];
|
||||
|
||||
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
|
||||
trpcCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route("**/api/v2/slots/available**", async (route) => {
|
||||
apiV2Calls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const totalCalls = trpcCalls.length + apiV2Calls.length;
|
||||
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
|
||||
page,
|
||||
`/org/${org.slug}/${team.slug}/${teamEvent.slug}`,
|
||||
beginningOfMonth
|
||||
);
|
||||
|
||||
expect(totalCalls).toBeGreaterThan(0);
|
||||
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls.length).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should detect when schedule endpoints are called multiple times for organization team events - end of month", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const orgOwner = await users.create(
|
||||
{ name: "Org Owner" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: [{ name: "teammate-1" }],
|
||||
isOrg: true,
|
||||
isOrgVerified: true,
|
||||
hasSubteam: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await orgOwner.getFirstTeamMembership();
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
const teamEvent = await createTeamEventType(
|
||||
{ id: orgOwner.id },
|
||||
{ id: team.id },
|
||||
{ teamEventSlug: "org-team-event", teamEventTitle: "Org Team Event" }
|
||||
);
|
||||
const endOfMonth = new Date();
|
||||
endOfMonth.setDate(20);
|
||||
|
||||
const { totalCalls, trpcCalls, apiV2Calls } = await testDuplicateAPICalls(
|
||||
page,
|
||||
`/org/${org.slug}/${team.slug}/${teamEvent.slug}`,
|
||||
endOfMonth
|
||||
);
|
||||
|
||||
expect(totalCalls).toBeGreaterThan(0);
|
||||
expect(totalCalls).toBeLessThanOrEqual(1);
|
||||
expect(trpcCalls).toBeLessThanOrEqual(1);
|
||||
expect(apiV2Calls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,9 +541,10 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
orderBy: { id: "asc" },
|
||||
const secondAttendeeId = bookingAttendees.find((attendee)=> attendee.email === "second+seats@cal.com");
|
||||
|
||||
const references = await prisma.bookingSeat.findFirst({
|
||||
where: { bookingId: booking.id, attendeeId: secondAttendeeId?.id },
|
||||
});
|
||||
|
||||
const secondUser = await users.create({
|
||||
@@ -556,13 +557,14 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
await page.waitForSelector('[data-testid="bookings"]');
|
||||
|
||||
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
|
||||
const href = await page.locator('[data-testid="reschedule"]').getAttribute("href");
|
||||
expect(href).toBeTruthy();
|
||||
const url = new URL(href!, page.url());
|
||||
const seatReferenceUid = url.searchParams.get('seatReferenceUid');
|
||||
expect(seatReferenceUid).toBeTruthy();
|
||||
expect(seatReferenceUid).toBe(references?.referenceUid);
|
||||
await page.locator('[data-testid="reschedule"]').click();
|
||||
|
||||
await page.waitForURL((url) => {
|
||||
const rescheduleUid = url.searchParams.get("rescheduleUid");
|
||||
return !!rescheduleUid && rescheduleUid === references[1].referenceUid;
|
||||
});
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
const nameElement = page.locator("input[name=name]");
|
||||
|
||||
@@ -117,6 +117,7 @@ export function createAppsFixture(page: Page) {
|
||||
},
|
||||
goToAppsTab: async () => {
|
||||
await page.getByTestId("vertical-tab-apps").click();
|
||||
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page");
|
||||
},
|
||||
activeApp: async (app: string) => {
|
||||
await page.locator(`[data-testid='${app}-app-switch']`).click();
|
||||
@@ -126,7 +127,7 @@ export function createAppsFixture(page: Page) {
|
||||
},
|
||||
verifyAppsInfoNew: async (app: string, eventTypeId: number) => {
|
||||
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
|
||||
await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); // fix the race condition
|
||||
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page"); // fix the race condition
|
||||
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
/**
|
||||
* Utility function to complete the organization onboarding wizard steps
|
||||
* (about, add-teams, onboard-members)
|
||||
*/
|
||||
async function completeOnboardingWizard(
|
||||
page: Page,
|
||||
options: {
|
||||
aboutText?: string;
|
||||
teams?: string[];
|
||||
members?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
|
||||
|
||||
// Step 1: Complete "About" step
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
await page.locator('textarea[name="about"]').fill(aboutText);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 2: Complete "Add Teams" step
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
for (let i = 0; i < teams.length; i++) {
|
||||
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
|
||||
if (i < teams.length - 1) {
|
||||
await page.getByTestId("add_a_team").click();
|
||||
}
|
||||
}
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 3: Complete "Onboard Members" step
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
|
||||
// Add members if provided
|
||||
for (const memberEmail of members) {
|
||||
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
|
||||
}
|
||||
|
||||
// Click publish and wait for response
|
||||
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
|
||||
await page.getByTestId("publish-button").click();
|
||||
|
||||
const response = await responsePromise;
|
||||
const responseData = await response.json();
|
||||
|
||||
// TRPC batch response is an array with one element
|
||||
const result = responseData[0].result.data.json;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Admin Handover Flow (Billing Disabled)", () => {
|
||||
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
|
||||
// Setup: Create admin and future owner
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const ownerEmail = users.trackEmail({
|
||||
username: "orgowner",
|
||||
domain: "example.com",
|
||||
});
|
||||
const ownerUser = await users.create({
|
||||
username: "orgowner",
|
||||
email: ownerEmail,
|
||||
});
|
||||
|
||||
const orgName = "Test Organization";
|
||||
const orgSlug = `test-org-${uuid()}`.toLowerCase();
|
||||
|
||||
// Step 1: Admin fills form
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("10");
|
||||
await page.locator("input[name=pricePerSeat]").fill("15");
|
||||
}
|
||||
|
||||
// Submit and wait for intentToCreateOrg
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
|
||||
// Step 2: Verify admin is on handover page
|
||||
await page.waitForURL("**/settings/organizations/new/handover");
|
||||
|
||||
const onboardingUrlElement = page.getByTestId("onboarding-url");
|
||||
await expect(onboardingUrlElement).toBeVisible();
|
||||
|
||||
const onboardingUrl = await onboardingUrlElement.textContent();
|
||||
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
|
||||
|
||||
// Extract onboardingId from URL
|
||||
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
|
||||
expect(onboardingIdMatch).toBeTruthy();
|
||||
const onboardingId = onboardingIdMatch?.[1];
|
||||
|
||||
// Step 3: Switch to owner user
|
||||
await page.context().clearCookies();
|
||||
await ownerUser.apiLogin();
|
||||
|
||||
// Step 4: Owner opens resume URL and completes the onboarding wizard
|
||||
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
|
||||
|
||||
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
|
||||
const result = await completeOnboardingWizard(page, {
|
||||
aboutText: "This is our test organization",
|
||||
teams: ["Engineering", "Marketing"],
|
||||
members: [memberEmail],
|
||||
});
|
||||
|
||||
// Verify the organization was created successfully in the API response
|
||||
expect(result.organizationId).toBeTruthy();
|
||||
expect(result.name).toBe(orgName);
|
||||
expect(result.slug).toBe(orgSlug);
|
||||
});
|
||||
|
||||
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const orgName = "Admin Org";
|
||||
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Fill form with admin's own email
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("5");
|
||||
await page.locator("input[name=pricePerSeat]").fill("20");
|
||||
}
|
||||
|
||||
// Submit - admin creating for self goes through wizard
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Admin creating for self goes through the onboarding wizard
|
||||
const result = await completeOnboardingWizard(page, {
|
||||
aboutText: "Admin's organization",
|
||||
teams: ["Operations"],
|
||||
members: [],
|
||||
});
|
||||
|
||||
// Verify the organization was created successfully
|
||||
expect(result.organizationId).toBeTruthy();
|
||||
expect(result.name).toBe(orgName);
|
||||
expect(result.slug).toBe(orgSlug);
|
||||
});
|
||||
|
||||
test("Admin handover URL structure is correct", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const ownerEmail = users.trackEmail({
|
||||
username: "testowner",
|
||||
domain: "example.com",
|
||||
});
|
||||
await users.create({
|
||||
username: "testowner",
|
||||
email: ownerEmail,
|
||||
});
|
||||
|
||||
const orgName = "Handover Test";
|
||||
const orgSlug = `handover-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("10");
|
||||
await page.locator("input[name=pricePerSeat]").fill("15");
|
||||
}
|
||||
|
||||
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Wait for the response
|
||||
await responsePromise;
|
||||
|
||||
// Wait for redirect to handover page
|
||||
await page.waitForURL("**/settings/organizations/new/handover");
|
||||
|
||||
// Wait for handover page elements to be visible (with longer timeout for store to populate)
|
||||
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
|
||||
|
||||
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
|
||||
|
||||
// Verify URL format
|
||||
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
|
||||
|
||||
// Verify copy button is clickable (toast message may vary by locale)
|
||||
await page.getByTestId("copy-onboarding-url").click();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Non-Admin User Flow", () => {
|
||||
test("Regular user creates org through full wizard", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "regularuser",
|
||||
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
const orgName = "User Organization";
|
||||
const orgSlug = `user-org-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify user email is pre-filled and disabled
|
||||
const emailInput = page.locator("input[name=orgOwnerEmail]");
|
||||
await expect(emailInput).toBeDisabled();
|
||||
await expect(emailInput).toHaveValue(user.email);
|
||||
|
||||
// Verify billing fields are NOT visible to regular users
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await expect(page.locator("input[name=seats]")).not.toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
|
||||
|
||||
// Verify "Upgrade to Organizations" UI is shown
|
||||
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
|
||||
}
|
||||
|
||||
// Fill form
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Verify navigated to /about step
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
|
||||
// Complete about step
|
||||
await page.locator('textarea[name="about"]').fill("User's organization description");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Complete add-teams step
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
await page.getByTestId("team.0.name").fill("Team Alpha");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Complete onboard-members step
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.getByTestId("publish-button").click(),
|
||||
]);
|
||||
|
||||
// In billing enabled mode, should redirect to Stripe or payment
|
||||
// In billing disabled mode, org created immediately
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
// Would redirect to Stripe checkout
|
||||
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
|
||||
} else {
|
||||
// After org creation, session update causes redirect to profile page
|
||||
// Wait for it to load, then navigate to organizations
|
||||
await page.waitForURL("**/settings/my-account/profile");
|
||||
await page.goto("/settings/organizations");
|
||||
await page.waitForURL(/\/settings\/organizations/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Regular user - email field is disabled", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "testuser",
|
||||
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify email field state
|
||||
const emailInput = page.locator("input[name=orgOwnerEmail]");
|
||||
await expect(emailInput).toBeVisible();
|
||||
await expect(emailInput).toBeDisabled();
|
||||
await expect(emailInput).toHaveValue(user.email);
|
||||
|
||||
// Verify name and slug are derived from email domain
|
||||
const nameInput = page.locator("input[name=name]");
|
||||
const slugInput = page.locator("input[name=slug]");
|
||||
|
||||
// Name should be capitalized version of domain
|
||||
const nameValue = await nameInput.inputValue();
|
||||
expect(nameValue).toBeTruthy();
|
||||
expect(nameValue).toMatch(/company/i);
|
||||
|
||||
// Slug should be domain-based
|
||||
const slugValue = await slugInput.inputValue();
|
||||
expect(slugValue).toBeTruthy();
|
||||
expect(slugValue).toContain("company");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Form Validation", () => {
|
||||
test("Admin form - required fields validation", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Submit empty form
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Should show validation errors
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
|
||||
});
|
||||
|
||||
test("Slug already taken shows error", async ({ page, users, orgs }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
// Create an existing org with a specific slug
|
||||
const existingSlug = `existing-${uuid()}`.toLowerCase();
|
||||
await orgs.create({
|
||||
name: "Existing Org",
|
||||
slug: existingSlug,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
|
||||
await users.create({ username: "owner", email: ownerEmail });
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill("New Org");
|
||||
await page.locator("input[name=slug]").fill(existingSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("5");
|
||||
await page.locator("input[name=pricePerSeat]").fill("10");
|
||||
}
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Should show slug taken error
|
||||
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Navigation Between Steps", () => {
|
||||
test("User can navigate through wizard steps", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "wizarduser",
|
||||
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
const orgName = "Wizard Test";
|
||||
const orgSlug = `wizard-${uuid()}`.toLowerCase();
|
||||
|
||||
// Step 1
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 2: About
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
await page.locator('textarea[name="about"]').fill("Wizard test org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 3: Add teams
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
await page.getByTestId("team.0.name").fill("Team 1");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 4: Onboard members
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
|
||||
// Verify wizard shows correct step numbers
|
||||
// The WizardLayout component shows currentStep/maxSteps
|
||||
// Just verify we reached the last step
|
||||
await expect(page.getByTestId("publish-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Billing Field Visibility", () => {
|
||||
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
|
||||
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify billing fields are visible for admin
|
||||
await expect(page.locator("input[name=seats]")).toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
|
||||
await expect(page.locator("#billingPeriod")).toBeVisible();
|
||||
|
||||
// Verify toggle between monthly/annual
|
||||
await expect(page.getByText("Monthly")).toBeVisible();
|
||||
await expect(page.getByText("Annually")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "pricinguser",
|
||||
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Should NOT see admin billing fields
|
||||
await expect(page.locator("input[name=seats]")).not.toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
|
||||
await expect(page.locator("#billingPeriod")).not.toBeVisible();
|
||||
|
||||
// Should see upgrade/pricing UI
|
||||
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
|
||||
await expect(page.getByText(/organization/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Messages } from "mailhog";
|
||||
import path from "path";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { fillStripeTestCheckout } from "../lib/testUtils";
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
|
||||
async function expectEmailWithSubject(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
|
||||
const allEmails = (receivedEmails as Messages).items;
|
||||
const email = allEmails.find((email) => email.subject === subject);
|
||||
if (!email) {
|
||||
throw new Error(`Email with subject ${subject} not found`);
|
||||
}
|
||||
const dom = new JSDOM(email.html);
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
|
||||
const document = dom?.window?.document;
|
||||
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
oldUsername,
|
||||
newUsername,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
oldUsername: string;
|
||||
newUsername: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
});
|
||||
const document = dom?.window.document;
|
||||
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
|
||||
if (!links) {
|
||||
throw new Error(`data-testid="organization-link-info doesn't have links`);
|
||||
}
|
||||
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
|
||||
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
|
||||
}
|
||||
|
||||
export async function expectEmailVerificationEmailToBeSent(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string
|
||||
) {
|
||||
const subject = "Cal.com: Verify your account";
|
||||
return expectEmailWithSubject(page, emails, userEmail, subject);
|
||||
}
|
||||
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
function capitalize(text: string) {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
|
||||
const orgOwnerUsernamePrefix = "owner";
|
||||
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
email: orgOwnerEmail,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
|
||||
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
|
||||
const orgName = capitalize(`${orgOwnerUser.username}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
await test.step("Handover", async () => {
|
||||
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
|
||||
expect(onboardingUrl).toContain("settings/organizations/new");
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
const orgOwnerUsername = `owner`;
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
orgSlug,
|
||||
});
|
||||
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
|
||||
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
|
||||
});
|
||||
|
||||
test("User can create and upgrade a org", async ({ page, users, emails }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
});
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
|
||||
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgOwnerUsername);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.getByTestId("add_a_team").click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const numberOfTeams = 3;
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create(
|
||||
{
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
},
|
||||
{ hasTeam: true, numberOfTeams }
|
||||
);
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Has org self serve banner", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
|
||||
await expect(selfServeButtonLocator).toBeVisible();
|
||||
|
||||
await selfServeButtonLocator.click();
|
||||
await page.waitForURL("/settings/organizations/new");
|
||||
});
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
const slugLocator = await page.locator("input[name=slug]");
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await slugLocator.inputValue()).toBe("example");
|
||||
|
||||
await slugLocator.fill(`example-${stringUUID}`);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Move existing teams to org", async () => {
|
||||
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
|
||||
const foundCheckboxes = page.locator('input[type="checkbox"]');
|
||||
const count = await foundCheckboxes.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const checkbox = foundCheckboxes.nth(i);
|
||||
await checkbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.getByTestId("new-team-dialog-button").click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
await page.goto("/teams");
|
||||
const teamListItems = await page.getByTestId("team-list-item-link").all();
|
||||
|
||||
// Number of teams migrated + the two created in the create teams step
|
||||
expect(teamListItems.length).toBe(numberOfTeams + 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function fillAndSubmitFirstStepAsAdmin(
|
||||
page: Page,
|
||||
targetOrgEmail: string,
|
||||
orgName: string,
|
||||
orgSlug: string
|
||||
) {
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
|
||||
// Since we are admin fill in this information instead of deriving it
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// Fill in seat information
|
||||
await page.locator("input[name=seats]").fill("30");
|
||||
await page.locator("input[name=pricePerSeat]").fill("30");
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
}
|
||||
@@ -653,11 +653,12 @@ test.describe("Out of office", () => {
|
||||
test("Default date range filter set to `Last 7 Days`", async ({ page, users }) => {
|
||||
const user = await users.create({ name: `userOne=${Date.now()}` });
|
||||
await user.apiLogin();
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForResponse(
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await responsePromise;
|
||||
await addFilter(page, "dateRange");
|
||||
await expect(
|
||||
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
|
||||
@@ -667,11 +668,12 @@ test.describe("Out of office", () => {
|
||||
test("Can choose date range presets", async ({ page, users }) => {
|
||||
const user = await users.create({ name: `userOne=${Date.now()}` });
|
||||
await user.apiLogin();
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForResponse(
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||
);
|
||||
await page.goto("/settings/my-account/out-of-office");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await responsePromise;
|
||||
await addFilter(page, "dateRange");
|
||||
|
||||
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from "./lib/testUtils";
|
||||
@@ -8,6 +9,11 @@ import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function goToAppsTab(page: Page, eventTypeId?: number) {
|
||||
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
|
||||
await expect(page.getByTestId("vertical-tab-apps")).toHaveAttribute("aria-current", "page");
|
||||
}
|
||||
|
||||
test.describe("Payment app", () => {
|
||||
test("Should be able to edit alby price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
@@ -29,7 +35,7 @@ test.describe("Payment app", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.getByPlaceholder("Price").click();
|
||||
@@ -75,7 +81,7 @@ test.describe("Payment app", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
@@ -115,7 +121,7 @@ test.describe("Payment app", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
@@ -158,7 +164,7 @@ test.describe("Payment app", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
@@ -185,7 +191,7 @@ test.describe("Payment app", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
@@ -220,7 +226,7 @@ test.describe("Payment app", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
// make sure Tracking ID is displayed
|
||||
@@ -267,7 +273,7 @@ test.describe("Payment app", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("[data-testid='paypal-app-switch']").click();
|
||||
await page.locator("[data-testid='stripe-app-switch']").isDisabled();
|
||||
@@ -314,7 +320,7 @@ test.describe("Payment app", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.getByTestId("paypal-app-switch").click();
|
||||
await page.getByTestId("paypal-price-input").fill("100");
|
||||
|
||||
@@ -793,6 +793,7 @@
|
||||
"remove_attribute": "Remove attribute",
|
||||
"remove_attribute_confirmation_message": "Are you sure you want to remove the attribute '{{name}}'? All users currently assigned to it will be unassigned.",
|
||||
"delete": "Delete",
|
||||
"view": "View",
|
||||
"update": "Update",
|
||||
"save": "Save",
|
||||
"pending": "Pending",
|
||||
@@ -1188,6 +1189,8 @@
|
||||
"featured_categories": "Featured Categories",
|
||||
"feature_flags": "Feature Flags",
|
||||
"admin_flags_description": "Here you can toggle your Cal.com instance features.",
|
||||
"feature_assigned_successfully": "Feature assigned successfully",
|
||||
"feature_unassigned_successfully": "Feature unassigned successfully",
|
||||
"popular_categories": "Popular Categories",
|
||||
"number_apps_one": "{{count}} App",
|
||||
"number_apps_other": "{{count}} Apps",
|
||||
@@ -2853,6 +2856,7 @@
|
||||
"organization_price_per_user_month": "$37 per user per month",
|
||||
"privacy_organization_description": "Manage privacy settings for your organization",
|
||||
"privacy": "Privacy",
|
||||
"privacy_and_security": "Privacy & Security",
|
||||
"team_will_be_under_org": "New teams will be under your organization",
|
||||
"add_group_name": "Add group name",
|
||||
"group_name": "Group Name",
|
||||
@@ -3295,6 +3299,12 @@
|
||||
"organization_onboarding_already_completed": "Organization onboarding already completed",
|
||||
"organization_onboarding_already_exists": "Organization onboarding already exists",
|
||||
"organization_onboarding_not_found": "Organization onboarding not found",
|
||||
"no_onboarding_id_provided": "No onboarding ID provided",
|
||||
"onboarding_not_found": "Onboarding not found",
|
||||
"onboarding_already_complete": "Onboarding already complete",
|
||||
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
|
||||
"resume_onboarding": "Resume Onboarding",
|
||||
"resume_onboarding_description": "Continue with your organization onboarding process",
|
||||
"payment_successful": "Payment successful!",
|
||||
"handover_onboarding_page_title": "Handover onboarding",
|
||||
"handover_onboarding_page_description": "Handover onboarding to the user",
|
||||
@@ -3545,6 +3555,7 @@
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Availability",
|
||||
"pbac_resource_out_of_office": "Out of Office",
|
||||
"pbac_resource_blocklist": "Blocklist",
|
||||
"pbac_desc_create_webhooks": "Create webhooks",
|
||||
"pbac_desc_view_webhooks": "View webhooks",
|
||||
"pbac_desc_update_webhooks": "Update webhooks",
|
||||
@@ -3623,6 +3634,8 @@
|
||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||
"download_expense_log": "Download Expense Log",
|
||||
"error_downloading_expense_log": "Error downloading expense log",
|
||||
"require_booker_email_verification": "Prevent Impersonation on Bookings",
|
||||
"require_booker_email_verification_description": "When enabled, anyone trying to book events using your email address must verify they own it via a one time code or be logged in to prevent impersonation",
|
||||
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
|
||||
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
|
||||
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
|
||||
@@ -3811,5 +3824,26 @@
|
||||
"edit_configuration": "Edit Configuration",
|
||||
"select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.",
|
||||
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
|
||||
"create_block_entry": "Create Block Entry",
|
||||
"blocklist_entry_details": "Blocklist Entry Details",
|
||||
"blocklist_entry_not_found": "Blocklist entry not found",
|
||||
"blocklist_entry_created": "Blocklist entry created successfully",
|
||||
"blocklist_entry_deleted": "Blocklist entry deleted successfully",
|
||||
"audit_history": "Audit History",
|
||||
"no_audit_history": "No audit history available",
|
||||
"delete_blocklist_entry": "Delete Blocklist Entry",
|
||||
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
|
||||
"created_by": "Created By",
|
||||
"blocked_by": "Blocked By",
|
||||
"by": "By",
|
||||
"added": "Added",
|
||||
"no_description_provided": "No description provided",
|
||||
"organization_blocklist": "Organization Blocklist",
|
||||
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
|
||||
"invalid_domain_format": "Invalid domain format. Example: example.com",
|
||||
"invalid_email_address": "Invalid email address. Example: user@example.com",
|
||||
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
|
||||
"what_would_you_like_to_block": "What would you like to block?",
|
||||
"domain_name": "Domain Name",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@ import { encode } from "querystring";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
|
||||
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
|
||||
import { getBrandingForUser } from "@calcom/features/profile/lib/getBranding";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
@@ -33,6 +34,9 @@ type UserPageProps = {
|
||||
requestedSlug: string | null;
|
||||
slug: string | null;
|
||||
id: number | null;
|
||||
brandColor: string | null;
|
||||
darkBrandColor: string | null;
|
||||
theme: string | null;
|
||||
} | null;
|
||||
allowSEOIndexing: boolean;
|
||||
username: string | null;
|
||||
@@ -130,15 +134,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
|
||||
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
|
||||
|
||||
const branding = getBrandingForUser({ user });
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username || "",
|
||||
image: getUserAvatarUrl({
|
||||
avatarUrl: user.avatarUrl,
|
||||
}),
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
|
||||
theme: branding.theme,
|
||||
brandColor: branding.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
|
||||
avatarUrl: user.avatarUrl,
|
||||
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
|
||||
darkBrandColor:
|
||||
branding.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
username: user.username,
|
||||
organization: user.profile.organization,
|
||||
|
||||
@@ -9,12 +9,12 @@ import type { z } from "zod";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
|
||||
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
||||
import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/dayjs";
|
||||
import type { HttpError } from "@calcom/lib/http-error";
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
||||
import type { BookingReference, Attendee, Booking, Membership } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/client";
|
||||
@@ -240,6 +240,8 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
|
||||
end: string;
|
||||
}[];
|
||||
};
|
||||
requiresBookerEmailVerification?: boolean;
|
||||
secondaryEmails?: { email: string; emailVerified: Date | null }[];
|
||||
};
|
||||
|
||||
export type InputEventType = {
|
||||
@@ -834,6 +836,21 @@ export async function addUsersToDb(users: InputUser[]) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (user.secondaryEmails) {
|
||||
log.debug("Creating SecondaryEmail entries for user", user.id);
|
||||
for (const secondaryEmail of user.secondaryEmails) {
|
||||
await prismock.secondaryEmail.create({
|
||||
data: {
|
||||
email: secondaryEmail.email,
|
||||
emailVerified: secondaryEmail.emailVerified,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allUsers = await prismock.user.findMany({
|
||||
include: {
|
||||
credentials: true,
|
||||
@@ -845,6 +862,7 @@ export async function addUsersToDb(users: InputUser[]) {
|
||||
},
|
||||
},
|
||||
destinationCalendar: true,
|
||||
secondaryEmails: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1554,6 +1572,8 @@ export function getOrganizer({
|
||||
username,
|
||||
locked,
|
||||
emailVerified,
|
||||
requiresBookerEmailVerification,
|
||||
secondaryEmails,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -1572,6 +1592,8 @@ export function getOrganizer({
|
||||
username?: string;
|
||||
locked?: boolean;
|
||||
emailVerified?: Date | null;
|
||||
requiresBookerEmailVerification?: boolean;
|
||||
secondaryEmails?: { email: string; emailVerified: Date | null }[];
|
||||
}) {
|
||||
username = username ?? TestData.users.example.username;
|
||||
return {
|
||||
@@ -1594,6 +1616,8 @@ export function getOrganizer({
|
||||
completedOnboarding,
|
||||
locked,
|
||||
emailVerified,
|
||||
requiresBookerEmailVerification,
|
||||
secondaryEmails,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type CommonPropsMockRequestData = {
|
||||
rescheduledBy?: string;
|
||||
cancelledBy?: string;
|
||||
schedulingType?: SchedulingType;
|
||||
guests?: string[];
|
||||
responses: {
|
||||
email: string;
|
||||
name: string;
|
||||
|
||||
@@ -15649,6 +15649,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BookerActiveBookingsLimit_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maximumActiveBookings": {
|
||||
"type": "number",
|
||||
"description": "The maximum number of active bookings a booker can have for this event type.",
|
||||
"example": 3
|
||||
},
|
||||
"offerReschedule": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached."
|
||||
}
|
||||
}
|
||||
},
|
||||
"Disabled_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15986,6 +16000,19 @@
|
||||
},
|
||||
"required": ["type"]
|
||||
},
|
||||
"EmailSettings_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"disableEmailsToAttendees": {
|
||||
"type": "boolean",
|
||||
"description": "Disables all email communication to attendees for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows."
|
||||
},
|
||||
"disableEmailsToHosts": {
|
||||
"type": "boolean",
|
||||
"description": "Disables all email communication to hosts for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CalVideoSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16146,6 +16173,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookerActiveBookingsLimit": {
|
||||
"description": "Limit the number of active bookings a booker can make for this event type.",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Disabled_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"onlyShowFirstAvailableSlot": {
|
||||
"type": "boolean",
|
||||
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
|
||||
@@ -17203,6 +17241,20 @@
|
||||
},
|
||||
"required": ["type", "slug", "label", "required", "placeholder", "hidden", "isDefault"]
|
||||
},
|
||||
"BookerActiveBookingsLimitOutput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maximumActiveBookings": {
|
||||
"type": "number",
|
||||
"description": "The maximum number of active bookings a booker can have for this event type.",
|
||||
"example": 3
|
||||
},
|
||||
"offerReschedule": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventTypeOutput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17389,6 +17441,9 @@
|
||||
"bookingLimitsCount": {
|
||||
"type": "object"
|
||||
},
|
||||
"bookerActiveBookingsLimit": {
|
||||
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
|
||||
},
|
||||
"onlyShowFirstAvailableSlot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -17673,6 +17728,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookerActiveBookingsLimit": {
|
||||
"description": "Limit the number of active bookings a booker can make for this event type.",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Disabled_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"onlyShowFirstAvailableSlot": {
|
||||
"type": "boolean",
|
||||
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
|
||||
@@ -19244,6 +19310,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookerActiveBookingsLimit": {
|
||||
"description": "Limit the number of active bookings a booker can make for this event type.",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Disabled_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"onlyShowFirstAvailableSlot": {
|
||||
"type": "boolean",
|
||||
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
|
||||
@@ -19420,6 +19497,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"emailSettings": {
|
||||
"description": "Email settings for this event type - only available for organization team event types.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rescheduleWithSameRoundRobinHost": {
|
||||
"type": "boolean",
|
||||
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
|
||||
}
|
||||
},
|
||||
"required": ["lengthInMinutes", "title", "slug", "schedulingType"]
|
||||
@@ -19653,6 +19742,9 @@
|
||||
"bookingLimitsCount": {
|
||||
"type": "object"
|
||||
},
|
||||
"bookerActiveBookingsLimit": {
|
||||
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
|
||||
},
|
||||
"onlyShowFirstAvailableSlot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -19755,6 +19847,18 @@
|
||||
},
|
||||
"team": {
|
||||
"$ref": "#/components/schemas/EventTypeTeam"
|
||||
},
|
||||
"emailSettings": {
|
||||
"description": "Email settings for this event type. Only available for organization team event types.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rescheduleWithSameRoundRobinHost": {
|
||||
"type": "boolean",
|
||||
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20043,6 +20147,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookerActiveBookingsLimit": {
|
||||
"description": "Limit the number of active bookings a booker can make for this event type.",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Disabled_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"onlyShowFirstAvailableSlot": {
|
||||
"type": "boolean",
|
||||
"description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
|
||||
@@ -20213,6 +20328,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"emailSettings": {
|
||||
"description": "Email settings for this event type. Only available for organization team event types.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rescheduleWithSameRoundRobinHost": {
|
||||
"type": "boolean",
|
||||
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+8
-6
@@ -209,17 +209,19 @@
|
||||
"group": "Atoms",
|
||||
"icon": "atom",
|
||||
"pages": [
|
||||
"platform/atoms/cal-provider",
|
||||
"platform/atoms/google-calendar-connect",
|
||||
"platform/atoms/outlook-calendar-connect",
|
||||
"platform/atoms/apple-calendar-connect",
|
||||
"platform/atoms/availability-settings",
|
||||
"platform/atoms/booker",
|
||||
"platform/atoms/event-type",
|
||||
"platform/atoms/cal-provider",
|
||||
"platform/atoms/calendar-settings",
|
||||
"platform/atoms/payment-form",
|
||||
"platform/atoms/calendar-view",
|
||||
"platform/atoms/conferencing-apps",
|
||||
"platform/atoms/calendar-view"
|
||||
"platform/atoms/create-schedule",
|
||||
"platform/atoms/event-type",
|
||||
"platform/atoms/google-calendar-connect",
|
||||
"platform/atoms/list-schedules",
|
||||
"platform/atoms/outlook-calendar-connect",
|
||||
"platform/atoms/payment-form"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: "Create schedule"
|
||||
---
|
||||
|
||||
The Create Schedule atom provides a simple dialog interface for users to create new availability
|
||||
schedules. Fully customizable with callback support for handling successful schedule creation.
|
||||
|
||||
Below code snippet can be used to render the Create schedule atom
|
||||
|
||||
```js
|
||||
import { CreateSchedule } from "@calcom/atoms";
|
||||
|
||||
export default function CreateSchedule() {
|
||||
return (
|
||||
<>
|
||||
<CreateSchedule
|
||||
name="Create new schedule"
|
||||
customClassNames={{
|
||||
createScheduleButton: "bg-red-500 border-none my-4 mx-2 rounded-md",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
For a demonstration of the Create schedule atom, please refer to the video below.
|
||||
|
||||
<p></p>
|
||||
|
||||
<iframe
|
||||
height="315"
|
||||
style={{ width: "100%", maxWidth: "560px" }}
|
||||
src="https://www.loom.com/embed/2d80dd0dddb64c6fb755b00091bdbced?sid=540f2e02-085e-4240-8b64-ca269cf2bb08"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
|
||||
<p></p>
|
||||
|
||||
For developers who don't want the dialog-based interface, we provide a `CreateScheduleForm` atom to integrate the schedule
|
||||
creation form directly into your own UI. This headless approach gives you full flexibility to
|
||||
handle layout, styling, and user flow exactly how you need it.
|
||||
|
||||
Below code snippet can be used to render the Create schedule form atom
|
||||
|
||||
```js
|
||||
import { CreateScheduleForm } from "@calcom/atoms";
|
||||
|
||||
export default function CreateScheduleForm() {
|
||||
return (
|
||||
<>
|
||||
<CreateScheduleForm
|
||||
customClassNames={{
|
||||
atomsWrapper: "border-black border-[1px] w-[500px] my-10 mx-5 rounded-md px-5 py-5",
|
||||
buttons: {
|
||||
continue: "bg-red-400 border-none",
|
||||
container: "justify-start",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
For a demonstration of the Create schedule form, please refer to the video below.
|
||||
|
||||
<p></p>
|
||||
|
||||
<iframe
|
||||
height="315"
|
||||
style={{ width: "100%", maxWidth: "560px" }}
|
||||
src="https://www.loom.com/embed/6f019f1ba49a43439032d2d6b434c17c?sid=448e105f-14f2-4735-979d-30470e7c10d1"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
|
||||
<p></p>
|
||||
|
||||
We offer all kinds of customizations to the Create schedule atom via props. Below is a list of props that can be passed to the atom.
|
||||
|
||||
<p></p>
|
||||
|
||||
| Name | Required | Description |
|
||||
| :--------------- | :------- | :----------------------------------------------------------------------- |
|
||||
| name | No | The label for the create schedule button |
|
||||
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
||||
| onSuccess | No | Callback function that handles successful creation of schedule |
|
||||
| onError | No | Callback function to handles errors at the time of schedule creation |
|
||||
| disableToasts | No | Boolean value that determines whether the toasts are displayed or not |
|
||||
|
||||
|
||||
Along with the props, create schedule atom accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.
|
||||
<p></p>
|
||||
| Name | Description |
|
||||
|:---------------------------|:-----------------------------------------------------------------------|
|
||||
| createScheduleButton | Adds styling to the create button |
|
||||
| inputField | Adds styling to the container of the name input field |
|
||||
| formWrapper | Adds styling to the whole form |
|
||||
| actionsButtons | Object containing classnames for the submit, cancel buttons and container inside the create schedule atom |
|
||||
|
||||
Similar to the create schedule atom, the create schedule form also offer all kinds of customizations via props. Below is a list of props that can be passed to the atom.
|
||||
<p></p>
|
||||
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
||||
| onSuccess | No | Callback function that handles successful creation of schedule |
|
||||
| onError | No | Callback function to handles errors at the time of schedule creation |
|
||||
| disableToasts | No | Boolean value that determines whether the toasts are displayed or not |
|
||||
|
||||
Along with the props, create schedule form also accepts custom styles via the **customClassNames** prop. Below is a list of props that fall under this **customClassNames** prop.
|
||||
|
||||
| Name | Description |
|
||||
|:---------------------------|:-----------------------------------------------------------------------|
|
||||
| formWrapper | Adds styling to the whole form |
|
||||
| inputField | Adds styling to the container of the name input field |
|
||||
| actionsButtons | Object containing classnames for the submit, cancel buttons and container inside the create schedule atom |
|
||||
|
||||
|
||||
<Note>
|
||||
Please ensure all custom classnames are valid Tailwind CSS classnames. Note that sometimes the custom
|
||||
classnames may fail to override the styling with the classnames that you might have passed via props. That
|
||||
is because the clsx utility function that we use to override classnames inside our components has some
|
||||
limitations. A simple get around to solve this issue is to just prefix your classnames with ! property just
|
||||
before passing in any classname.
|
||||
</Note>
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: "List schedules"
|
||||
---
|
||||
|
||||
The List schedules atom displays all of a user's availability schedules and provides management actions such as delete, duplicate, and edit capabilities for each schedule.
|
||||
|
||||
Below code snippet can be used to render the List schedules atom
|
||||
|
||||
```js
|
||||
import { ListSchedules } from "@calcom/atoms";
|
||||
|
||||
export default function ListSchedules() {
|
||||
return (
|
||||
<>
|
||||
<ListSchedules getRedirectUrl={(scheduleId) => `/availability/${scheduleId}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For a demonstration of the List schedules atom, please refer to the video below.
|
||||
|
||||
<p></p>
|
||||
|
||||
<iframe
|
||||
height="315"
|
||||
style={{ width: "100%", maxWidth: "560px" }}
|
||||
src="https://www.loom.com/embed/8e020a4848d543548c519d3a7cd532c6?sid=e6ccad23-8372-402a-8fd3-c0180bc9113c"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
|
||||
<p></p>
|
||||
|
||||
Below is a list of props that can be passed to the List schedules atom.
|
||||
|
||||
<p></p>
|
||||
|
||||
| Name | Required | Description |
|
||||
| :------------- | :------- | :---------------------------------------------------------------------------------------------------------------- |
|
||||
| getRedirectUrl | No | Callback function that determines where the user should be redirected when they click on a schedule from the atom |
|
||||
|
||||
## Combining list schedules atom with availability settings atom
|
||||
|
||||
The List schedules atom works best when combined with the Availability settings atom to provide a seamless experience. In order to combine both of atom, here are the steps we recommend:
|
||||
|
||||
<Steps>
|
||||
<Step title="Setup list schedules atom">
|
||||
Create a page for demostrating the list schedules atom.
|
||||
|
||||
Below code snippet can be used to setup the list schedules atom on a new page
|
||||
|
||||
```js
|
||||
import { ListSchedules } from "@calcom/atoms";
|
||||
|
||||
export default function ListSchedules() {
|
||||
return (
|
||||
<>
|
||||
<ListSchedules getRedirectUrl={(scheduleId) => `/availability/${scheduleId}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`getRedirectUrl` prop is a callback function that determines where the user should be redirected when they click on a schedule from the atom. Say if you want to display your availability settings atom on the page `/availability/:scheduleId`, you need to make sure you return the exact same url when you call the `getRedirectUrl` function.
|
||||
|
||||
</Step>
|
||||
<Step title="Setup availability settings atom">
|
||||
Create a page for demonstrating the availability settings atom. This should be a dynamic route that accepts a schedule id as a query parameter, and then the schedule id gets passed to the availability settings atom.
|
||||
|
||||
Below code snippet can be used to obtain the `scheduleId` and display the availability settings atom
|
||||
|
||||
```js
|
||||
import { AvailabilitySettings } from "@calcom/atoms";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function Availability() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AvailabilitySettings
|
||||
id={router.query.scheduleId as string}
|
||||
onUpdateSuccess={() => {
|
||||
console.log("Updated schedule successfully");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
-Below video shows a demonstration of how we combined the list schedules atom and availability settings atom in one of our example app to create a seamless availability management experience.
|
||||
|
||||
<p></p>
|
||||
|
||||
<iframe
|
||||
height="315"
|
||||
style={{ width: "100%", maxWidth: "560px" }}
|
||||
src="https://www.loom.com/embed/76b65330e61e47eb95b7072ed3a42ec8?sid=9373ec6f-ba55-431e-98de-5dd3fb252a8a"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
|
||||
<p></p>
|
||||
|
||||
<Info>
|
||||
Link to the example app can be found
|
||||
[here](https://github.com/calcom/cal.com/tree/main/packages/platform/examples/base)
|
||||
</Info>
|
||||
@@ -1,3 +0,0 @@
|
||||
## Infrastructure Folder
|
||||
|
||||
This folder, located within the "infra" directory of our monorepo, is dedicated to managing the infrastructure as code (IaC) and Docker-related files for our project. It plays a critical role in orchestrating, configuring, and maintaining the infrastructure that our applications rely on.
|
||||
@@ -1,73 +0,0 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Adjust NODE_VERSION as desired
|
||||
ARG NODE_VERSION=20.7.0
|
||||
FROM node:${NODE_VERSION}-slim as base
|
||||
|
||||
ARG DATABASE_URL
|
||||
|
||||
# Next.js/Prisma app lives here
|
||||
WORKDIR /app
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV="production" \
|
||||
DATABASE_URL=$DATABASE_URL \
|
||||
DATABASE_DIRECT_URL=$DATABASE_URL
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base as build
|
||||
|
||||
# copy all required files from the monorepo
|
||||
COPY package.json yarn.lock .yarnrc.yml playwright.config.ts turbo.json git-init.sh git-setup.sh ./
|
||||
COPY /.yarn ./.yarn
|
||||
COPY ./apps/api ./apps/api
|
||||
COPY ./packages ./packages
|
||||
# TODO: follow up pr to remove dependencies on web
|
||||
COPY ./apps/web ./apps/web
|
||||
COPY ./tests ./tests
|
||||
|
||||
# Install node modules and dependencies, prune unneeded deps, then build
|
||||
RUN set -eux; \
|
||||
apt-get update -qq && \
|
||||
apt-get install -y build-essential openssl pkg-config python-is-python3 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
|
||||
yarn config set httpTimeout 1200000 && \
|
||||
npx turbo prune --filter=@calcom/web --docker && \
|
||||
npx turbo prune --filter=@calcom/api --docker && \
|
||||
yarn install && \
|
||||
yarn turbo run build --filter=@calcom/api
|
||||
|
||||
|
||||
# Final stage
|
||||
FROM base
|
||||
WORKDIR /app
|
||||
|
||||
# Install packages needed for deployment
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y openssl && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Copy built application
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=build /app/apps/api/next-i18next.config.js ./apps/api/next-i18next.config.js
|
||||
COPY --from=build /app/apps/api/next.config.js ./apps/api/next.config.js
|
||||
COPY --from=build /app/apps/api/tsconfig.json ./apps/api/tsconfig.json
|
||||
COPY --from=build /app/apps/api/.next ./apps/api/.next
|
||||
COPY --from=build /app/apps/api/.turbo ./apps/api/.turbo
|
||||
COPY --from=build /app/turbo.json ./turbo.json
|
||||
COPY --from=build /app/yarn.lock ./yarn.lock
|
||||
COPY --from=build /app/packages/config ./packages/config
|
||||
COPY --from=build /app/packages/tsconfig ./packages/tsconfig
|
||||
COPY --from=build /app/packages/types ./packages/types
|
||||
COPY --from=build /app/apps/web/next.config.js ./apps/web/next.config.js
|
||||
COPY --from=build /app/apps/web/next-i18next.config.js ./apps/web/next-i18next.config.js
|
||||
COPY --from=build /app/apps/web/public/static/locales ./apps/web/public/static/locales
|
||||
COPY --from=build /app/apps/web/package.json ./apps/web/package.json
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start cmd, called when docker image is mounted
|
||||
CMD [ "yarn", "workspace", "@calcom/api", "docker-start-api"]
|
||||
@@ -77,9 +77,6 @@
|
||||
"type-check": "turbo run type-check",
|
||||
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
|
||||
"web": "yarn workspace @calcom/web",
|
||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||
"changesets-add": "yarn changeset add",
|
||||
"changesets-version": "yarn changeset version",
|
||||
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type z from "zod";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
|
||||
import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
bucket_region: process.env.CAL_VIDEO_BUCKET_REGION,
|
||||
assume_role_arn: process.env.CAL_VIDEO_ASSUME_ROLE_ARN,
|
||||
allow_api_access: true,
|
||||
allow_streaming_from_bucket: true,
|
||||
allow_streaming_from_bucket: false,
|
||||
},
|
||||
}),
|
||||
enable_transcription_storage: isTranscriptionEnabled,
|
||||
@@ -367,7 +367,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
bucket_region: process.env.CAL_VIDEO_BUCKET_REGION,
|
||||
assume_role_arn: process.env.CAL_VIDEO_ASSUME_ROLE_ARN,
|
||||
allow_api_access: true,
|
||||
allow_streaming_from_bucket: true,
|
||||
allow_streaming_from_bucket: false,
|
||||
},
|
||||
}),
|
||||
start_video_off: true,
|
||||
|
||||
@@ -4,8 +4,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata";
|
||||
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
|
||||
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
|
||||
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
|
||||
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { SMSLockState, RRTimestampBasis } from "@calcom/prisma/enums";
|
||||
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
|
||||
@@ -28,7 +28,7 @@ vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository",
|
||||
}));
|
||||
|
||||
// Mock DelegationCredentialRepository
|
||||
vi.mock("@calcom/lib/server/repository/delegationCredential", () => ({
|
||||
vi.mock("@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository", () => ({
|
||||
DelegationCredentialRepository: {
|
||||
findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey: vi.fn(),
|
||||
findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey: vi.fn(),
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
} from "@calcom/lib/delegationCredential";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
|
||||
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { SelectedCalendar } from "@calcom/prisma/client";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dub } from "dub-package";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
|
||||
@@ -466,7 +466,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
args: FreeBusyArgs,
|
||||
shouldServeCache?: boolean
|
||||
): Promise<calendar_v3.Schema$FreeBusyResponse> {
|
||||
if (shouldServeCache === false) return await this.fetchAvailability(args);
|
||||
if (!shouldServeCache) return await this.fetchAvailability(args);
|
||||
const calendarCache = await CalendarCache.init(null);
|
||||
const cached = await calendarCache.getCachedAvailability({
|
||||
credentialId: this.credential.id,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test, beforeEach, vi, describe } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
import CalendarService from "../CalendarService";
|
||||
|
||||
describe("GoogleCalendarService.getFreeBusyResult - shouldServeCache logic", () => {
|
||||
let calendarService: CalendarService;
|
||||
let fetchAvailabilitySpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
calendarService = {} as CalendarService;
|
||||
|
||||
const mockFetchAvailability = vi.fn().mockResolvedValue({
|
||||
calendars: {
|
||||
"test@example.com": {
|
||||
busy: [
|
||||
{
|
||||
start: "2023-12-01T20:00:00Z",
|
||||
end: "2023-12-01T21:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
calendarService.fetchAvailability = mockFetchAvailability;
|
||||
fetchAvailabilitySpy = mockFetchAvailability;
|
||||
|
||||
calendarService.getFreeBusyResult = CalendarService.prototype.getFreeBusyResult.bind(calendarService);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(calendarService as any).credential = { id: 1, userId: 1 };
|
||||
});
|
||||
|
||||
describe("shouldServeCache parameter handling", () => {
|
||||
test("should call fetchAvailability immediately when shouldServeCache is explicitly false", async () => {
|
||||
const args = {
|
||||
timeMin: new Date().toISOString(),
|
||||
timeMax: new Date().toISOString(),
|
||||
items: [{ id: "test@example.com" }],
|
||||
};
|
||||
|
||||
const result = await calendarService.getFreeBusyResult(args, false);
|
||||
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
|
||||
{
|
||||
start: "2023-12-01T20:00:00Z",
|
||||
end: "2023-12-01T21:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should call fetchAvailability immediately when shouldServeCache is undefined (falsey)", async () => {
|
||||
const args = {
|
||||
timeMin: new Date().toISOString(),
|
||||
timeMax: new Date().toISOString(),
|
||||
items: [{ id: "test@example.com" }],
|
||||
};
|
||||
|
||||
const result = await calendarService.getFreeBusyResult(args, undefined);
|
||||
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
|
||||
{
|
||||
start: "2023-12-01T20:00:00Z",
|
||||
end: "2023-12-01T21:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should call fetchAvailability immediately when shouldServeCache is null (falsey)", async () => {
|
||||
const args = {
|
||||
timeMin: new Date().toISOString(),
|
||||
timeMax: new Date().toISOString(),
|
||||
items: [{ id: "test@example.com" }],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await calendarService.getFreeBusyResult(args, null as any);
|
||||
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
|
||||
{
|
||||
start: "2023-12-01T20:00:00Z",
|
||||
end: "2023-12-01T21:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should call fetchAvailability immediately when shouldServeCache is 0 (falsey)", async () => {
|
||||
const args = {
|
||||
timeMin: new Date().toISOString(),
|
||||
timeMax: new Date().toISOString(),
|
||||
items: [{ id: "test@example.com" }],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await calendarService.getFreeBusyResult(args, 0 as any);
|
||||
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
|
||||
{
|
||||
start: "2023-12-01T20:00:00Z",
|
||||
end: "2023-12-01T21:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should call fetchAvailability immediately when shouldServeCache is empty string (falsey)", async () => {
|
||||
const args = {
|
||||
timeMin: new Date().toISOString(),
|
||||
timeMax: new Date().toISOString(),
|
||||
items: [{ id: "test@example.com" }],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await calendarService.getFreeBusyResult(args, "" as any);
|
||||
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args);
|
||||
expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.calendars?.["test@example.com"]?.busy).toEqual([
|
||||
{
|
||||
start: "2023-12-01T20:00:00Z",
|
||||
end: "2023-12-01T21:00:00Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
SimplePublicObjectInput,
|
||||
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
|
||||
|
||||
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -21,10 +21,6 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import type { HubspotToken } from "../api/callback";
|
||||
|
||||
interface CustomPublicObjectInput extends SimplePublicObjectInput {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default class HubspotCalendarService implements CRM {
|
||||
private url = "";
|
||||
private integrationName = "";
|
||||
@@ -117,8 +113,14 @@ export default class HubspotCalendarService implements CRM {
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
||||
};
|
||||
|
||||
private hubspotDeleteMeeting = async (uid: string) => {
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
||||
private hubspotCancelMeeting = async (uid: string) => {
|
||||
const simplePublicObjectInput: SimplePublicObjectInput = {
|
||||
properties: {
|
||||
hs_meeting_outcome: "CANCELED",
|
||||
},
|
||||
};
|
||||
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
||||
};
|
||||
|
||||
private hubspotAuth = async (credential: CredentialPayload) => {
|
||||
@@ -204,7 +206,6 @@ export default class HubspotCalendarService implements CRM {
|
||||
return await this.handleMeetingCreation(event, contacts);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
|
||||
const auth = await this.auth;
|
||||
await auth.getToken();
|
||||
@@ -214,7 +215,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
async deleteEvent(uid: string): Promise<void> {
|
||||
const auth = await this.auth;
|
||||
await auth.getToken();
|
||||
return await this.hubspotDeleteMeeting(uid);
|
||||
await this.hubspotCancelMeeting(uid);
|
||||
}
|
||||
|
||||
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import {
|
||||
entityPrismaWhereClause,
|
||||
canEditEntity,
|
||||
} from "@calcom/features/pbac/lib/entityPermissionUtils.server";
|
||||
import { sanitizeValue } from "@calcom/lib/csvUtils";
|
||||
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma";
|
||||
|
||||
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { WorkflowService } from "@calcom/lib/server/service/workflows";
|
||||
import {
|
||||
WebhookTriggerEvents,
|
||||
WorkflowTriggerEvents,
|
||||
@@ -37,7 +37,7 @@ vi.mock("@calcom/features/tasker", () => {
|
||||
});
|
||||
|
||||
// Mock workflow dependencies
|
||||
vi.mock("@calcom/lib/server/service/workflows", () => ({
|
||||
vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService", () => ({
|
||||
WorkflowService: {
|
||||
getAllWorkflowsFromRoutingForm: vi.fn(() => Promise.resolve([])),
|
||||
scheduleFormWorkflows: vi.fn(() => Promise.resolve()),
|
||||
|
||||
@@ -6,7 +6,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { WorkflowService } from "@calcom/lib/server/service/workflows";
|
||||
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
|
||||
import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { canCreateEntity, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
|
||||
import { canCreateEntity, canEditEntity } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { App_RoutingForms_Form, User } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { routingFormIncompleteBookingDataSchema } from "../../zod";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
|
||||
import type { AttributeRoutingConfig } from "../../routing-forms/types/types";
|
||||
import SalesforceCRMService from "./CrmService";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import { validateAuthentication, buildReturnUrl } from "../portal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/lib/server/repository/team");
|
||||
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
|
||||
vi.mock("../../lib/customer");
|
||||
vi.mock("../../lib/server");
|
||||
vi.mock("../../lib/subscriptions");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
|
||||
export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
|
||||
const billingService = new StripeBillingService();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import stripe from "../../server";
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { BillingPortalService } from "../base/BillingPortalService";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import getLabelValueMapFromResponses from "@calcom/features/bookings/lib/getLabelValueMapFromResponses";
|
||||
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { Info } from "./Info";
|
||||
|
||||
@@ -32,7 +32,7 @@ import logger from "@calcom/lib/logger";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
||||
|
||||
@@ -6,7 +6,8 @@ import { getLocaleFromRequest } from "@calcom/features/auth/lib/getLocaleFromReq
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
|
||||
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
|
||||
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||
import { hashPassword } from "@calcom/lib/auth/hashPassword";
|
||||
@@ -15,7 +16,6 @@ import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { CustomNextApiHandler } from "@calcom/lib/server/username";
|
||||
import { usernameHandler } from "@calcom/lib/server/username";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -3,12 +3,12 @@ import { NextResponse } from "next/server";
|
||||
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
|
||||
import { hashPassword } from "@calcom/lib/auth/hashPassword";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user