Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da67be475f | ||
|
|
e639237088 | ||
|
|
5bc1fbb8c2 | ||
|
|
e310931417 | ||
|
|
17944311c3 | ||
|
|
d82f87aa2a | ||
|
|
6aa21aceb8 | ||
|
|
870ad57a7e | ||
|
|
fddee71829 | ||
|
|
6f15a5ab0f | ||
|
|
1fd64c5d7b | ||
|
|
f8614f11ff | ||
|
|
5e5e77236a | ||
|
|
4cee53b07c | ||
|
|
c289fac008 | ||
|
|
68f9e86cdd | ||
|
|
5da754db65 | ||
|
|
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 | ||
|
|
947b05bf90 | ||
|
|
a0e5e03903 | ||
|
|
09ee39a3d8 | ||
|
|
243012e82f | ||
|
|
d96c807505 | ||
|
|
dac1069181 | ||
|
|
06d6c180d1 | ||
|
|
a2bee76da6 | ||
|
|
52ecea964d | ||
|
|
f9b294cfc2 | ||
|
|
e5f14c9316 | ||
|
|
bed76beb2f | ||
|
|
ff38d6c7db | ||
|
|
1dc20e3aec | ||
|
|
44a3a9eabb | ||
|
|
c0c52750b6 | ||
|
|
3bcaf0ec22 | ||
|
|
b881e025f6 | ||
|
|
35d140e0a7 | ||
|
|
5fd55cbfc3 | ||
|
|
18c546a171 | ||
|
|
b01ceb427d | ||
|
|
eae2711f21 | ||
|
|
fac956b761 | ||
|
|
b286244aab | ||
|
|
c46f824e3f | ||
|
|
dbe55f0804 | ||
|
|
3bcce02b92 | ||
|
|
2a23911a5b | ||
|
|
5aa24c8472 | ||
|
|
8277ce934b | ||
|
|
bb68cd73ef | ||
|
|
445b307972 | ||
|
|
d2d86a4079 | ||
|
|
7601cc2194 | ||
|
|
aa48e72c12 | ||
|
|
bec49ef417 | ||
|
|
c13aa69565 | ||
|
|
1470adb159 | ||
|
|
bf89991eef | ||
|
|
4dfab874e0 | ||
|
|
4303fb8726 | ||
|
|
64f811ed0b | ||
|
|
4d51873140 | ||
|
|
3c0fc2e88b | ||
|
|
c702da0334 | ||
|
|
92169eb0d2 | ||
|
|
3d74357e96 | ||
|
|
6593291d00 | ||
|
|
c48fb44a8d | ||
|
|
e7c908bc80 | ||
|
|
ad35e19f8e | ||
|
|
3fc8c6c84e | ||
|
|
da7aeb4a21 | ||
|
|
6197a2a5c5 | ||
|
|
de186b95c1 | ||
|
|
6b24513073 | ||
|
|
55ea18128a | ||
|
|
d5557fa512 | ||
|
|
9c97b2aa6d | ||
|
|
1e226a40d1 | ||
|
|
bbe46eaa7d | ||
|
|
7799b191ec | ||
|
|
f3269a3ddf | ||
|
|
a4be822636 | ||
|
|
7cd8dbf7d8 | ||
|
|
654a865007 | ||
|
|
409a0277fb | ||
|
|
7adab1f65f | ||
|
|
3b92c86582 | ||
|
|
de195a1c0f | ||
|
|
d7d2487f6d | ||
|
|
3e31945157 | ||
|
|
c11814c342 | ||
|
|
60e3244088 | ||
|
|
a1ac5f36f0 | ||
|
|
6f38d40613 | ||
|
|
6b7d22de3f | ||
|
|
2f763dceda | ||
|
|
96468c4083 | ||
|
|
858d52563f | ||
|
|
7505b3b271 | ||
|
|
758dc88b55 | ||
|
|
9be418dc65 | ||
|
|
2ac310456e | ||
|
|
ff264d6f7a | ||
|
|
8019c414e1 | ||
|
|
a3fbe3731e | ||
|
|
e3742dbc66 | ||
|
|
83bf717d6d | ||
|
|
7fcdaa1c46 | ||
|
|
a8154f9405 | ||
|
|
52a5afeba5 | ||
|
|
43cc40d7fe | ||
|
|
42a98f000e | ||
|
|
f003ef935b | ||
|
|
e2b2a184c8 | ||
|
|
094595c435 | ||
|
|
4118a86c0d | ||
|
|
928d6f3821 | ||
|
|
b2ade568c8 | ||
|
|
27820ce897 | ||
|
|
fea7e66996 | ||
|
|
474d1571ae | ||
|
|
2ef627b87e | ||
|
|
2dfb82b623 | ||
|
|
ccd7fdfd52 | ||
|
|
f92e59ed34 | ||
|
|
d29477349a | ||
|
|
259e480baa | ||
|
|
0055dd1142 | ||
|
|
61c44780f0 | ||
|
|
9710d3b15c | ||
|
|
04cefebd75 | ||
|
|
dbd927c002 | ||
|
|
e6b2116a2b | ||
|
|
855a02091f | ||
|
|
358434429e | ||
|
|
b00061d85e | ||
|
|
022f08c05e | ||
|
|
6ff5c8ce5f | ||
|
|
3be7fb9dd8 | ||
|
|
72dd970b6e | ||
|
|
f4d09ece43 | ||
|
|
239e6085ba | ||
|
|
b17eab3aeb | ||
|
|
752f7fcde0 | ||
|
|
f6b061f787 | ||
|
|
cc8a1290ee | ||
|
|
b7cf893169 | ||
|
|
1458cacdbb |
+219
-1
@@ -111,7 +111,7 @@ The event types page UI components are located in `apps/web/modules/event-types/
|
||||
|
||||
Changes to shared UI patterns (like tab layouts and button alignments) need to be checked across multiple views to maintain consistency:
|
||||
- Event types page layout: `apps/web/modules/event-types/views/event-types-listing-view.tsx`
|
||||
- Bookings page layout: `apps/web/modules/bookings/views/bookings-listing-view.tsx`
|
||||
- Bookings page layout: `apps/web/modules/bookings/views/bookings-view.tsx`
|
||||
- Common elements like tabs, search bars, and filter buttons should maintain consistent alignment across views
|
||||
|
||||
## When working on workflow triggers or similar enum-based features in the Cal.com codebase
|
||||
@@ -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
|
||||
|
||||
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
|
||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||
GOOGLE_WEBHOOK_URL=
|
||||
|
||||
# Token to verify incoming webhooks from Microsoft Calendar
|
||||
MICROSOFT_WEBHOOK_TOKEN=
|
||||
|
||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||
MICROSOFT_WEBHOOK_URL=
|
||||
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
@@ -261,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
|
||||
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# 0 = false, 1=true
|
||||
NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER=
|
||||
|
||||
# Set the following value to true if you wish to enable Team Impersonation
|
||||
NEXT_PUBLIC_TEAM_IMPERSONATION=false
|
||||
|
||||
@@ -473,3 +482,7 @@ DATABASE_CHUNK_SIZE=
|
||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||
|
||||
SALESFORCE_GRAPHQL_DELAY_MS=500
|
||||
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
|
||||
SALESFORCE_GRAPHQL_MAX_RETRIES=3
|
||||
|
||||
+5
-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
|
||||
@@ -23,11 +22,12 @@
|
||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||
/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
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||
revision: docs/api-reference/v2/openapi.json
|
||||
fail-on: WARN
|
||||
fail-on: ERR
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
|
||||
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
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
@@ -234,9 +234,10 @@ for Logger level to be set at info, for example.
|
||||
|
||||
</details>
|
||||
|
||||
If you don't want to create a local DB. Then you can also consider using services like railway.app or render.
|
||||
If you don't want to create a local DB. Then you can also consider using services like railway.app, Northflank or render.
|
||||
|
||||
- [Setup postgres DB with railway.app](https://docs.railway.app/guides/postgresql)
|
||||
- [Setup postgres DB with Northflank](https://northflank.com/guides/deploy-postgres-database-on-northflank)
|
||||
- [Setup postgres DB with render](https://render.com/docs/databases)
|
||||
|
||||
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
|
||||
@@ -390,6 +391,12 @@ Cal.com, Inc. does not provide official support for Docker, but we will accept f
|
||||
|
||||
You can deploy Cal.com on [Railway](https://railway.app) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal.com on their platform.
|
||||
|
||||
### Northflank
|
||||
|
||||
[](https://northflank.com/stacks/deploy-calcom)
|
||||
|
||||
You can deploy Cal.com on [Northflank](https://northflank.com) using the button above. The team at Northflank also have a [detailed blog post](https://northflank.com/guides/deploy-calcom-with-northflank) on deploying Cal.com on their platform.
|
||||
|
||||
### Vercel
|
||||
|
||||
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
const safeGet = async <T = unknown>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
return get<T>(key);
|
||||
} catch (error) {
|
||||
// Don't crash if EDGE_CONFIG env var is missing
|
||||
}
|
||||
};
|
||||
|
||||
export const config = { matcher: "/:path*" };
|
||||
|
||||
export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => {
|
||||
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
|
||||
if (isInMaintenanceMode) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: "API is currently under maintenance. Please try again at a later time." });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import type { NextApiResponse, NextApiRequest } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { handleAutoLock } from "@calcom/lib/autoLock";
|
||||
import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/autoLock", () => ({
|
||||
vi.mock("@calcom/features/ee/api-keys/lib/autoLock", () => ({
|
||||
handleAutoLock: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { handleAutoLock } from "@calcom/lib/autoLock";
|
||||
import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
/**
|
||||
* Unit Tests for verifyApiKey middleware
|
||||
*
|
||||
* These tests verify the middleware logic without touching the database.
|
||||
* All dependencies (repositories, utilities) are mocked.
|
||||
*/
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
|
||||
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
|
||||
ApiKeyService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
|
||||
PrismaApiKeyRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/isAdmin", () => ({
|
||||
isAdminGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/isLockedOrBlocked", () => ({
|
||||
isLockedOrBlocked: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
||||
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
||||
@@ -32,17 +55,29 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const mockDeploymentRepository: IDeploymentRepository = {
|
||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"),
|
||||
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||
};
|
||||
|
||||
describe("Verify API key", () => {
|
||||
describe("Verify API key - Unit Tests", () => {
|
||||
let service: ILicenseKeyService;
|
||||
let mockApiKeyService: ApiKeyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = await LicenseKeyService.create(mockDeploymentRepository);
|
||||
|
||||
vi.spyOn(service, "checkLicense");
|
||||
|
||||
vi.spyOn(LicenseKeySingleton, "getInstance").mockResolvedValue(service as LicenseKeyService);
|
||||
|
||||
mockApiKeyService = {
|
||||
verifyKeyByHashedKey: vi.fn(),
|
||||
} as unknown as ApiKeyService;
|
||||
|
||||
vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService);
|
||||
vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository));
|
||||
|
||||
vi.mocked(isAdminGuard).mockReset();
|
||||
vi.mocked(isLockedOrBlocked).mockReset();
|
||||
});
|
||||
|
||||
it("should throw an error if the api key is not valid", async () => {
|
||||
@@ -98,22 +133,25 @@ describe("Verify API key", () => {
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
|
||||
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 1,
|
||||
user: {
|
||||
role: UserPermissionRole.ADMIN,
|
||||
locked: false,
|
||||
email: "admin@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||
isAdmin: true,
|
||||
scope: ScopeOfAdmin.SystemWide,
|
||||
});
|
||||
|
||||
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
@@ -139,40 +177,25 @@ describe("Verify API key", () => {
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "org-admin@acme.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
teams: {
|
||||
create: {
|
||||
accepted: true,
|
||||
role: MembershipRole.OWNER,
|
||||
team: {
|
||||
create: {
|
||||
name: "ACME",
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
create: {
|
||||
isAdminAPIEnabled: true,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 2,
|
||||
user: {
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
email: "org-admin@acme.com",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||
isAdmin: true,
|
||||
scope: ScopeOfAdmin.OrgOwnerOrAdmin,
|
||||
});
|
||||
|
||||
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
@@ -198,22 +221,25 @@ describe("Verify API key", () => {
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "locked@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
|
||||
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 3,
|
||||
user: {
|
||||
role: UserPermissionRole.USER,
|
||||
locked: true,
|
||||
email: "locked@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||
isAdmin: false,
|
||||
scope: ScopeOfAdmin.SystemWide,
|
||||
});
|
||||
|
||||
vi.mocked(isLockedOrBlocked).mockResolvedValue(true);
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
@@ -3,21 +3,15 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
const now = new Date();
|
||||
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const deploymentRepo = new DeploymentRepository(prisma);
|
||||
@@ -32,24 +26,19 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
|
||||
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||
const hashedKey = hashAPIKey(strippedApiKey);
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { hashedKey },
|
||||
include: {
|
||||
user: {
|
||||
select: { role: true, locked: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
|
||||
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
|
||||
return res.status(401).json({ error: "This API key is expired." });
|
||||
|
||||
// Use service layer for API key verification
|
||||
const apiKeyRepo = new PrismaApiKeyRepository(prisma);
|
||||
const apiKeyService = new ApiKeyService({ apiKeyRepo });
|
||||
const result = await apiKeyService.verifyKeyByHashedKey(hashedKey);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(401).json({ error: result.error });
|
||||
}
|
||||
if (!apiKey.userId || !apiKey.user)
|
||||
return res.status(404).json({ error: "No user found for this API key." });
|
||||
|
||||
// save the user id in the request for later use
|
||||
req.userId = apiKey.userId;
|
||||
req.user = apiKey.user;
|
||||
req.userId = result.userId!;
|
||||
req.user = result.user!;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { label } from "next-api-middleware";
|
||||
import { addRequestId } from "./addRequestid";
|
||||
import { captureErrors } from "./captureErrors";
|
||||
import { captureUserId } from "./captureUserId";
|
||||
import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode";
|
||||
import { extendRequest } from "./extendRequest";
|
||||
import {
|
||||
HTTP_DELETE,
|
||||
@@ -26,7 +25,6 @@ const middleware = {
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
checkIsInMaintenanceMode,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
extendRequest,
|
||||
@@ -38,16 +36,14 @@ const middleware = {
|
||||
|
||||
type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder = [
|
||||
// The order here, determines the order of execution
|
||||
"checkIsInMaintenanceMode",
|
||||
const middlewareOrder: Middleware[] = [
|
||||
"extendRequest",
|
||||
"captureErrors",
|
||||
"verifyApiKey",
|
||||
"rateLimitApiKey",
|
||||
"addRequestId",
|
||||
"captureUserId",
|
||||
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||
];
|
||||
|
||||
const withMiddleware = label(middleware, middlewareOrder);
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
|
||||
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||
|
||||
export async function isLockedOrBlocked(req: NextApiRequest) {
|
||||
const user = req.user;
|
||||
if (!user?.email) return false;
|
||||
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
|
||||
return (
|
||||
user.locked ||
|
||||
(await checkIfEmailIsBlockedInWatchlistController({
|
||||
email: user.email,
|
||||
organizationId: null,
|
||||
span: sentrySpan,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { iso8601 } from "@calcom/prisma/zod-utils";
|
||||
import { UserSchema } from "@calcom/prisma/zod/modelSchema/UserSchema";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
||||
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/app-store/videoClient";
|
||||
} from "@calcom/features/conferencing/lib/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
|
||||
import {
|
||||
getTranscriptsAccessLinkFromRecordingId,
|
||||
checkIfRoomNameMatchesInRecording,
|
||||
} from "@calcom/app-store/videoClient";
|
||||
} from "@calcom/features/conferencing/lib/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container";
|
||||
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
@@ -239,15 +239,17 @@ async function handler(req: NextApiRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
return await handleNewBooking(
|
||||
{
|
||||
bookingData: req.body,
|
||||
const regularBookingService = getRegularBookingService();
|
||||
|
||||
return await regularBookingService.createBookingForApiV1({
|
||||
bookingData: req.body,
|
||||
bookingMeta: {
|
||||
userId,
|
||||
hostname: req.headers.host || "",
|
||||
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
},
|
||||
getBookingDataSchemaForApi
|
||||
);
|
||||
bookingDataSchemaGetter: getBookingDataSchemaForApi,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const knownError = error as Error;
|
||||
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
|
||||
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
|
||||
import type { UserWithCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
||||
|
||||
/** Only OWNERS and ADMINS can edit teams */
|
||||
const _team = await prisma.team.findFirst({
|
||||
const team = await prisma.team.findFirst({
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { members: true },
|
||||
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||
});
|
||||
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
|
||||
if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
|
||||
|
||||
const slugAlreadyExists = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: {
|
||||
mode: "insensitive",
|
||||
equals: data.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (slugAlreadyExists && data.slug !== _team.slug)
|
||||
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||
if (data.slug) {
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
|
||||
slug: data.slug,
|
||||
teamId: team.id,
|
||||
parentId: team.parentId,
|
||||
});
|
||||
if (!isSlugAvailable) {
|
||||
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if parentId is related to this user
|
||||
if (data.parentId && data.parentId === teamId) {
|
||||
@@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
}
|
||||
|
||||
let paymentUrl;
|
||||
if (_team.slug === null && data.slug) {
|
||||
if (team.slug === null && data.slug) {
|
||||
data.metadata = {
|
||||
...(_team.metadata as Prisma.JsonObject),
|
||||
...(team.metadata as Prisma.JsonObject),
|
||||
requestedSlug: data.slug,
|
||||
};
|
||||
delete data.slug;
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
const checkoutSession = await purchaseTeamOrOrgSubscription({
|
||||
teamId: _team.id,
|
||||
seatsUsed: _team.members.length,
|
||||
teamId: team.id,
|
||||
seatsUsed: team.members.length,
|
||||
userId,
|
||||
pricePerSeat: null,
|
||||
});
|
||||
@@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||
const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||
const result = {
|
||||
team: schemaTeamReadPublic.parse(team),
|
||||
team: schemaTeamReadPublic.parse(updatedTeam),
|
||||
paymentUrl,
|
||||
};
|
||||
if (!paymentUrl) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
|
||||
import { deleteUser } from "@calcom/features/users/lib/deleteUser";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { UserCreationService } from "@calcom/features/users/services/userCreationService";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { UserCreationService } from "@calcom/lib/server/service/userCreationService";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/app-store/videoClient";
|
||||
} from "@calcom/features/conferencing/lib/videoClient";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
|
||||
const adminUserId = 1;
|
||||
const memberUserId = 10;
|
||||
|
||||
vi.mock("@calcom/app-store/videoClient", () => {
|
||||
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
|
||||
return {
|
||||
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
import {
|
||||
getTranscriptsAccessLinkFromRecordingId,
|
||||
checkIfRoomNameMatchesInRecording,
|
||||
} from "@calcom/app-store/videoClient";
|
||||
} from "@calcom/features/conferencing/lib/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/app-store/videoClient", () => {
|
||||
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
|
||||
return {
|
||||
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
||||
checkIfRoomNameMatchesInRecording: vi.fn(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/app-store/videoClient", () => {
|
||||
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
|
||||
return {
|
||||
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
|
||||
}));
|
||||
|
||||
const mockFindOriginalRescheduledBooking = vi.fn();
|
||||
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
||||
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
|
||||
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
||||
})),
|
||||
@@ -76,7 +76,7 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
|
||||
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
||||
vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({
|
||||
getQualifiedHostsService: vi.fn().mockReturnValue({
|
||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||
qualifiedRRHosts: [],
|
||||
@@ -133,7 +133,7 @@ vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", (
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
||||
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
|
||||
ProfileRepository: {
|
||||
findManyForUser: vi.fn().mockResolvedValue([]),
|
||||
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import handler from "../../../pages/api/selected-calendars/_post";
|
||||
|
||||
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
|
||||
|
||||
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||
id: 444444,
|
||||
} as any);
|
||||
} as User);
|
||||
|
||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||
credentialId: 1,
|
||||
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
/**
|
||||
* Unit Tests for POST /api/users
|
||||
*
|
||||
* These tests verify the API endpoint logic without touching the database.
|
||||
* All dependencies (UserCreationService) are mocked.
|
||||
*/
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import handler from "../../../pages/api/users/_post";
|
||||
|
||||
@@ -18,9 +22,41 @@ vi.mock("@calcom/lib/server/i18n", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/profile/lib/checkUsername", () => ({
|
||||
checkUsername: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
premium: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({
|
||||
checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const mockCreate = vi.fn();
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
|
||||
UserRepository: vi.fn().mockImplementation(() => ({
|
||||
create: mockCreate,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/auth/hashPassword", () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
|
||||
}));
|
||||
|
||||
vi.stubEnv("CALCOM_LICENSE_KEY", undefined);
|
||||
|
||||
describe("POST /api/users", () => {
|
||||
describe("POST /api/users - Unit Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw 401 if not system-wide admin", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -34,7 +70,9 @@ describe("POST /api/users", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw a 400 if no email is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -47,7 +85,9 @@ describe("POST /api/users", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw a 400 if no username is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -60,15 +100,24 @@ describe("POST /api/users", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should create user successfully", async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
username: "testuser123",
|
||||
locked: false,
|
||||
organizationId: null,
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
username: "testuser123",
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
@@ -76,57 +125,19 @@ describe("POST /api/users", () => {
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const userQuery = await prismock.user.findFirst({
|
||||
where: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(userQuery).toEqual(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
username: "testuser123",
|
||||
locked: false,
|
||||
organizationId: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should auto lock user if email is in watchlist", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
await prismock.watchlist.create({
|
||||
data: {
|
||||
type: "EMAIL",
|
||||
value: "test@example.com",
|
||||
severity: "CRITICAL",
|
||||
createdById: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const userQuery = await prismock.user.findFirst({
|
||||
where: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(userQuery).toEqual(
|
||||
const responseData = JSON.parse(res._getData());
|
||||
expect(responseData.user).toEqual(
|
||||
expect.objectContaining({
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
locked: true,
|
||||
username: "testuser123",
|
||||
organizationId: null,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { WatchlistSeverity } from "@calcom/prisma/enums";
|
||||
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import type { WatchlistFeature } from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
|
||||
|
||||
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
||||
|
||||
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
|
||||
getWatchlistFeature: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("isLockedOrBlocked", () => {
|
||||
beforeEach(async () => {
|
||||
await prismock.watchlist.createMany({
|
||||
data: [
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "spam.com",
|
||||
createdById: 1,
|
||||
},
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "blocked.com",
|
||||
severity: WatchlistSeverity.CRITICAL,
|
||||
createdById: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
let mockWatchlistFeature: WatchlistFeature;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockGlobalBlocking = {
|
||||
isBlocked: vi.fn(),
|
||||
};
|
||||
const mockOrgBlocking = {
|
||||
isBlocked: vi.fn(),
|
||||
};
|
||||
|
||||
mockWatchlistFeature = {
|
||||
globalBlocking: mockGlobalBlocking,
|
||||
orgBlocking: mockOrgBlocking,
|
||||
} as unknown as WatchlistFeature;
|
||||
|
||||
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should return false if no user in request", async () => {
|
||||
const req = { userId: null, user: null } as any;
|
||||
const req = { userId: null, user: null } as unknown as NextApiRequest;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false if user has no email", async () => {
|
||||
const req = { userId: 123, user: { email: null } } as any;
|
||||
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true if user is locked", async () => {
|
||||
@@ -44,10 +56,12 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: true,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true if user email domain is watchlisted", async () => {
|
||||
@@ -57,10 +71,14 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: false,
|
||||
email: "test@blocked.com",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
|
||||
});
|
||||
|
||||
it("should return false if user is not locked and email domain is not watchlisted", async () => {
|
||||
@@ -70,10 +88,14 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: false,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: false });
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@example.com");
|
||||
});
|
||||
|
||||
it("should handle email domains case-insensitively", async () => {
|
||||
@@ -83,9 +105,13 @@ describe("isLockedOrBlocked", () => {
|
||||
locked: false,
|
||||
email: "test@BLOCKED.COM",
|
||||
},
|
||||
} as any;
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
@@ -35,6 +38,9 @@ import { Module } from "@nestjs/common";
|
||||
SchedulesModule_2024_04_15,
|
||||
EventTypesModule_2024_06_14,
|
||||
ProfilesModule,
|
||||
RegularBookingModule,
|
||||
RecurringBookingModule,
|
||||
InstantBookingModule,
|
||||
],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings";
|
||||
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
|
||||
let eventTypeId: number;
|
||||
|
||||
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
|
||||
let createdBooking: RegularBookingCreateResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -131,8 +131,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
@@ -231,8 +230,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
@@ -289,8 +287,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
@@ -387,8 +384,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
|
||||
@@ -7,6 +7,9 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
@@ -45,11 +48,8 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants";
|
||||
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||
import {
|
||||
handleNewRecurringBooking,
|
||||
handleNewBooking,
|
||||
BookingResponse,
|
||||
HttpError,
|
||||
handleInstantMeeting,
|
||||
handleMarkNoShow,
|
||||
getAllUserBookings,
|
||||
getBookingInfo,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
ErrorCode,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreationSource } from "@calcom/platform-libraries";
|
||||
import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings";
|
||||
import {
|
||||
GetBookingsInput_2024_04_15,
|
||||
CancelBookingInput_2024_04_15,
|
||||
@@ -109,7 +110,10 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersService: UsersService,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -187,17 +191,19 @@ export class BookingsController_2024_04_15 {
|
||||
const { orgSlug, locationUrl } = body;
|
||||
try {
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await handleNewBooking({
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
forcedSlug: orgSlug,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
forcedSlug: orgSlug,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
});
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
@@ -315,15 +321,17 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
|
||||
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
},
|
||||
});
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
@@ -351,14 +359,15 @@ export class BookingsController_2024_04_15 {
|
||||
@Body() body: CreateBookingInput_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string,
|
||||
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
|
||||
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
||||
): Promise<ApiResponse<InstantBookingCreateResult>> {
|
||||
const oAuthClientId =
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
try {
|
||||
const instantMeeting = await handleInstantMeeting(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
||||
);
|
||||
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const instantMeeting = await this.instantBookingCreateService.createBooking({
|
||||
bookingData: bookingReq.body,
|
||||
});
|
||||
|
||||
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
||||
const now = new Date();
|
||||
|
||||
@@ -16,6 +16,9 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_0
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
@@ -58,6 +61,9 @@ import { Module } from "@nestjs/common";
|
||||
TeamsEventTypesModule,
|
||||
MembershipsModule,
|
||||
ProfilesModule,
|
||||
RegularBookingModule,
|
||||
RecurringBookingModule,
|
||||
InstantBookingModule,
|
||||
],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
|
||||
@@ -87,7 +87,7 @@ import {
|
||||
@DocsTags("Bookings")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_08_13}`,
|
||||
description: `Must be set to ${VERSION_2024_08_13}. If not set to this value, the endpoint will default to an older version.`,
|
||||
example: VERSION_2024_08_13,
|
||||
required: true,
|
||||
schema: {
|
||||
@@ -133,6 +133,8 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
@@ -188,6 +190,8 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async getBooking(
|
||||
@@ -206,7 +210,10 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get all the recordings for the booking",
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\``,
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
|
||||
const recordings = await this.calVideoService.getRecordings(bookingUid);
|
||||
@@ -221,7 +228,10 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get all the transcripts download links for the booking",
|
||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\``,
|
||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
|
||||
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
|
||||
@@ -236,7 +246,10 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiOperation({ summary: "Get all bookings" })
|
||||
@ApiOperation({
|
||||
summary: "Get all bookings",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async getBookings(
|
||||
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
@@ -263,7 +276,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Reschedule a booking",
|
||||
description: "Reschedule a booking or seated booking",
|
||||
description: `Reschedule a booking or seated booking
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
@@ -315,6 +331,8 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
@@ -350,7 +368,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Mark a booking absence",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -372,8 +393,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to auto-selected host",
|
||||
description:
|
||||
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -394,8 +417,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to a specific host",
|
||||
description:
|
||||
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -423,7 +448,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Confirm a booking",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async confirmBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -444,7 +472,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Decline a booking",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async declineBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -465,8 +496,10 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Add to Calendar' links for a booking",
|
||||
description:
|
||||
"Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.",
|
||||
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise<CalendarLinksOutput_2024_08_13> {
|
||||
@@ -485,6 +518,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get booking references",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
|
||||
@@ -65,6 +65,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let team1EventTypeId: number;
|
||||
let team2EventTypeId: number;
|
||||
let phoneOnlyEventTypeId: number;
|
||||
let collectiveEventWithoutHostsId: number;
|
||||
let roundRobinEventWithoutHostsId: number;
|
||||
|
||||
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||
@@ -223,6 +225,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
team1EventTypeId = team1EventType.id;
|
||||
|
||||
const team1CollectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
|
||||
slug: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
});
|
||||
collectiveEventWithoutHostsId = team1CollectiveEventTypeWithoutHosts.id;
|
||||
|
||||
const team1RoundRobinEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
|
||||
slug: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
|
||||
length: 60,
|
||||
});
|
||||
roundRobinEventWithoutHostsId = team1RoundRobinEventTypeWithoutHosts.id;
|
||||
|
||||
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
@@ -363,6 +387,52 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("cant book event types without hosts", () => {
|
||||
it("should fail to book a collective event type without hosts", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: collectiveEventWithoutHostsId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
timeZone: "Europe/Madrid",
|
||||
email: "alice@gmail.com",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${collectiveEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail to book a round robin event type without hosts", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: roundRobinEventWithoutHostsId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
timeZone: "Europe/Madrid",
|
||||
email: "alice@gmail.com",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.message).toBe(
|
||||
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${roundRobinEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create team bookings", () => {
|
||||
it("should create a team 1 booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
@@ -765,8 +835,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { getPagination } from "@/lib/pagination/pagination";
|
||||
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
UnprocessableEntityException,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -36,10 +40,8 @@ import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
handleNewRecurringBooking,
|
||||
getTranslation,
|
||||
getAllUserBookings,
|
||||
handleInstantMeeting,
|
||||
handleCancelBooking,
|
||||
roundRobinReassignment,
|
||||
roundRobinManualReassignment,
|
||||
@@ -47,7 +49,6 @@ import {
|
||||
confirmBookingHandler,
|
||||
getCalendarLinks,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateBookingInput,
|
||||
@@ -108,7 +109,10 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly membershipsService: MembershipsService,
|
||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
|
||||
private readonly regularBookingService: RegularBookingService,
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||
@@ -132,6 +136,10 @@ export class BookingsService_2024_08_13 {
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType.schedulingType === "COLLECTIVE" || eventType.schedulingType === "ROUND_ROBIN") {
|
||||
await this.checkEventTypeHasHosts(eventType.id);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
|
||||
if ("instant" in body && body.instant) {
|
||||
@@ -159,6 +167,15 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async checkEventTypeHasHosts(eventTypeId: number) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeWithHosts(eventTypeId);
|
||||
if (!eventType?.hosts?.length) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${eventTypeId} belonging to team with id=${eventType?.teamId} and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async checkBookingRequiresAuthenticationSetting(
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
authUser: AuthOptionalUser,
|
||||
@@ -450,7 +467,9 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
const booking = await this.instantBookingCreateService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
});
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||
booking.bookingId
|
||||
@@ -468,17 +487,19 @@ export class BookingsService_2024_08_13 {
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
const bookings = await this.recurringBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.noEmail,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.noEmail,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
});
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
@@ -491,16 +512,18 @@ export class BookingsService_2024_08_13 {
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
const bookings = await this.recurringBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
|
||||
@@ -514,16 +537,18 @@ export class BookingsService_2024_08_13 {
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleNewBooking({
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
@@ -546,16 +571,18 @@ export class BookingsService_2024_08_13 {
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
try {
|
||||
const booking = await handleNewBooking({
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
@@ -741,16 +768,18 @@ export class BookingsService_2024_08_13 {
|
||||
bookingUid,
|
||||
body
|
||||
);
|
||||
const booking = await handleNewBooking({
|
||||
const booking = await this.regularBookingService.createBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
bookingMeta: {
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
});
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -817,7 +817,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
|
||||
return {
|
||||
uid: bookingUid,
|
||||
cancellationReason: "",
|
||||
cancellationReason: inputBooking.cancellationReason || "",
|
||||
allRemainingBookings: false,
|
||||
seatReferenceUid: inputBooking.seatUid,
|
||||
};
|
||||
|
||||
+5
-1
@@ -137,7 +137,11 @@ export class EventTypesService_2024_04_15 {
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push({ ...systemBeforeFieldEmail, type: BaseField.email, editable: Editable.system });
|
||||
bookingFields.push({
|
||||
...systemBeforeFieldEmail,
|
||||
type: BaseField.email,
|
||||
editable: Editable.systemButOptional,
|
||||
});
|
||||
}
|
||||
|
||||
await updateEventType({
|
||||
|
||||
+232
-17
@@ -31,15 +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,
|
||||
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 }) => {
|
||||
@@ -1441,29 +1444,200 @@ 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);
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(eventType.id);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(falseTestUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
try {
|
||||
await userRepositoryFixture.delete(orgUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -2187,6 +2361,47 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split name booking field", () => {
|
||||
it("should create an event type with split name booking field", async () => {
|
||||
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
|
||||
isDefault: true,
|
||||
type: "splitName",
|
||||
slug: "splitName",
|
||||
firstNameLabel: "First name",
|
||||
firstNamePlaceholder: "",
|
||||
lastNameLabel: "last name",
|
||||
lastNamePlaceholder: "",
|
||||
lastNameRequired: false,
|
||||
disableOnPrefill: false,
|
||||
};
|
||||
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 9",
|
||||
slug: "coding-class-9",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookingFields: [splitNameBookingField],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
|
||||
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
|
||||
(field) => field.type === "splitName"
|
||||
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
@@ -2194,12 +2409,12 @@ describe("Event types Endpoints", () => {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -2379,7 +2594,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
+expect(updatedEventType.hidden).toBe(false);
|
||||
expect(updatedEventType.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2389,12 +2604,12 @@ describe("Event types Endpoints", () => {
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+21
-7
@@ -61,7 +61,7 @@ import {
|
||||
@DocsTags("Event Types")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_06_14}`,
|
||||
description: `Must be set to ${VERSION_2024_06_14}. If not set to this value, the endpoint will default to an older version.`,
|
||||
example: VERSION_2024_06_14,
|
||||
required: true,
|
||||
schema: {
|
||||
@@ -80,7 +80,10 @@ export class EventTypesController_2024_06_14 {
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Create an event type" })
|
||||
@ApiOperation({
|
||||
summary: "Create an event type",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -102,7 +105,10 @@ export class EventTypesController_2024_06_14 {
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get an event type" })
|
||||
@ApiOperation({
|
||||
summary: "Get an event type",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -122,8 +128,10 @@ export class EventTypesController_2024_06_14 {
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Get all event types",
|
||||
description:
|
||||
"Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
|
||||
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@UseGuards(OptionalApiAuthGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@@ -149,7 +157,10 @@ export class EventTypesController_2024_06_14 {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an event type" })
|
||||
@ApiOperation({
|
||||
summary: "Update an event type",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async updateEventType(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeInput_2024_06_14,
|
||||
@@ -173,7 +184,10 @@ export class EventTypesController_2024_06_14 {
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Delete an event type" })
|
||||
@ApiOperation({
|
||||
summary: "Delete an event type",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
|
||||
+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<
|
||||
|
||||
+1
-1
@@ -556,7 +556,7 @@ export const systemBeforeFieldEmail: EmailSystemField = {
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: true,
|
||||
editable: "system",
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
|
||||
+23
-6
@@ -40,7 +40,7 @@ import {
|
||||
@DocsTags("Schedules")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_06_11}`,
|
||||
description: `Must be set to ${VERSION_2024_06_11}. If not set to this value, the endpoint will default to an older version.`,
|
||||
example: VERSION_2024_06_11,
|
||||
required: true,
|
||||
schema: {
|
||||
@@ -69,6 +69,8 @@ export class SchedulesController_2024_06_11 {
|
||||
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
||||
|
||||
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async createSchedule(
|
||||
@@ -91,7 +93,10 @@ export class SchedulesController_2024_06_11 {
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Get default schedule",
|
||||
description: "Get the default schedule of the authenticated user.",
|
||||
description: `Get the default schedule of the authenticated user.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
@@ -104,7 +109,10 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiOperation({ summary: "Get a schedule" })
|
||||
@ApiOperation({
|
||||
summary: "Get a schedule",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
@@ -121,7 +129,10 @@ export class SchedulesController_2024_06_11 {
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiOperation({
|
||||
summary: "Get all schedules",
|
||||
description: "Get all schedules of the authenticated user.",
|
||||
description: `Get all schedules of the authenticated user.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
@@ -134,7 +145,10 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Patch("/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@ApiOperation({ summary: "Update a schedule" })
|
||||
@ApiOperation({
|
||||
summary: "Update a schedule",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async updateSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
|
||||
@@ -155,7 +169,10 @@ export class SchedulesController_2024_06_11 {
|
||||
@Delete("/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@ApiOperation({ summary: "Delete a schedule" })
|
||||
@ApiOperation({
|
||||
summary: "Delete a schedule",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async deleteSchedule(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BookingCancelService } from "@/lib/services/booking-cancel.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [BookingCancelService],
|
||||
exports: [BookingCancelService],
|
||||
})
|
||||
export class BookingCancelModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
providers: [InstantBookingCreateService],
|
||||
exports: [InstantBookingCreateService],
|
||||
})
|
||||
export class InstantBookingModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [RegularBookingModule],
|
||||
providers: [RecurringBookingService],
|
||||
exports: [RecurringBookingService],
|
||||
})
|
||||
export class RecurringBookingModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
PrismaAttributeRepository,
|
||||
PrismaBookingRepository,
|
||||
PrismaFeaturesRepository,
|
||||
PrismaHostRepository,
|
||||
PrismaOOORepository,
|
||||
PrismaUserRepository,
|
||||
CacheService,
|
||||
CheckBookingAndDurationLimitsService,
|
||||
CheckBookingLimitsService,
|
||||
LuckyUserService,
|
||||
RegularBookingService,
|
||||
],
|
||||
exports: [RegularBookingService],
|
||||
})
|
||||
export class RegularBookingModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { BookingCancelService as BaseBookingCancelService } from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
|
||||
@Injectable()
|
||||
export class BookingCancelService extends BaseBookingCancelService {
|
||||
constructor(
|
||||
prismaWriteService: PrismaWriteService,
|
||||
) {
|
||||
super({
|
||||
prismaClient: prismaWriteService.prisma,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { InstantBookingCreateService as BaseInstantBookingCreateService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class InstantBookingCreateService extends BaseInstantBookingCreateService {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class RecurringBookingService extends BaseRecurringBookingService {
|
||||
constructor(regularBookingService: RegularBookingService) {
|
||||
super({
|
||||
regularBookingService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class RegularBookingService extends BaseRegularBookingService {
|
||||
constructor(
|
||||
cacheService: CacheService,
|
||||
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
|
||||
prismaWriteService: PrismaWriteService,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
luckyUserService: LuckyUserService,
|
||||
userRepository: PrismaUserRepository
|
||||
) {
|
||||
super({
|
||||
cacheService,
|
||||
checkBookingAndDurationLimitsService,
|
||||
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
|
||||
bookingRepository,
|
||||
luckyUserService,
|
||||
userRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class AtomsSecondaryEmailsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getSecondaryEmailsVerified(userId: number) {
|
||||
return await this.dbRead.prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
userId_email: { userId, email },
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmail(email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async addSecondaryEmailVerified(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
||||
data: {
|
||||
userId,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
AtomsSecondaryEmailsRepository,
|
||||
UsersService,
|
||||
SchedulesAtomsService,
|
||||
VerificationAtomsService,
|
||||
|
||||
@@ -64,47 +64,4 @@ export class AtomsRepository {
|
||||
|
||||
return userTeams;
|
||||
}
|
||||
|
||||
async getSecondaryEmails(userId: number) {
|
||||
return await this.dbRead.prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
userId_email: { userId, email },
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmail(email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async addSecondaryEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
||||
data: {
|
||||
userId,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
|
||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
|
||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
@Injectable()
|
||||
export class VerificationAtomsService {
|
||||
constructor(
|
||||
private readonly atomsRepository: AtomsRepository,
|
||||
private readonly teamsRepository: TeamsRepository
|
||||
private readonly atomsSecondaryEmailsRepository: AtomsSecondaryEmailsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
||||
@@ -76,7 +76,8 @@ export class VerificationAtomsService {
|
||||
|
||||
if (teamId) {
|
||||
const verifiedEmails: string[] = [];
|
||||
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
|
||||
|
||||
const teamMembers = await this.usersRepository.getUserEmailsVerifiedForTeam(teamId);
|
||||
|
||||
if (teamMembers.length === 0) {
|
||||
return verifiedEmails;
|
||||
@@ -96,7 +97,7 @@ export class VerificationAtomsService {
|
||||
|
||||
let verifiedEmails = [userEmailWithoutOauthClientId];
|
||||
|
||||
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
|
||||
const secondaryEmails = await this.atomsSecondaryEmailsRepository.getSecondaryEmailsVerified(userId);
|
||||
verifiedEmails = verifiedEmails.concat(
|
||||
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
|
||||
);
|
||||
@@ -113,11 +114,9 @@ export class VerificationAtomsService {
|
||||
existingPrimaryEmail: string;
|
||||
email: string;
|
||||
}): Promise<boolean> {
|
||||
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
|
||||
userId,
|
||||
email
|
||||
);
|
||||
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
|
||||
const existingSecondaryEmail =
|
||||
await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmailByUserAndEmail(userId, email);
|
||||
const alreadyExistingEmail = await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmail(email);
|
||||
|
||||
if (alreadyExistingEmail) {
|
||||
throw new BadRequestException("Email already exists");
|
||||
@@ -127,7 +126,7 @@ export class VerificationAtomsService {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.atomsRepository.addSecondaryEmail(userId, email);
|
||||
await this.atomsSecondaryEmailsRepository.addSecondaryEmailVerified(userId, email);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
+6
-3
@@ -9,7 +9,7 @@ import {
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class IsWorkflowInTeam implements CanActivate {
|
||||
export class IsEventTypeWorkflowInTeam implements CanActivate {
|
||||
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -41,10 +41,13 @@ export class IsWorkflowInTeam implements CanActivate {
|
||||
teamId: string,
|
||||
workflowId: string
|
||||
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
|
||||
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(
|
||||
Number(teamId),
|
||||
Number(workflowId)
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
|
||||
throw new NotFoundException(`IsWorkflowInTeam - event-type workflow (${workflowId}) not found.`);
|
||||
}
|
||||
|
||||
if (workflow.teamId === Number(teamId)) {
|
||||
@@ -0,0 +1,59 @@
|
||||
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class IsRoutingFormWorkflowInTeam implements CanActivate {
|
||||
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { workflow?: WorkflowType }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const workflowId: string = request.params.workflowId;
|
||||
|
||||
if (!workflowId) {
|
||||
throw new ForbiddenException("IsWorkflowInTeam - No workflow found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("IsWorkflowInTeam - No team id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, workflow } = await this.checkIfWorkflowIsInTeam(teamId, workflowId);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsTeamInOrg - Workflow with id=${workflowId} is not part of the team with id=${teamId}.`
|
||||
);
|
||||
}
|
||||
|
||||
request.workflow = workflow;
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkIfWorkflowIsInTeam(
|
||||
teamId: string,
|
||||
workflowId: string
|
||||
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(
|
||||
Number(teamId),
|
||||
Number(workflowId)
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`IsWorkflowInTeam - routing form workflow (${workflowId}) not found.`);
|
||||
}
|
||||
|
||||
if (workflow.teamId === Number(teamId)) {
|
||||
return { canAccess: true, workflow };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { hasMinimumPlan } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { orderedPlans, PlatformPlan } from "@/modules/billing/types";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsBillingService {
|
||||
@@ -14,8 +15,18 @@ export class ManagedOrganizationsBillingService {
|
||||
if (!managerOrgBilling) {
|
||||
throw new NotFoundException("Manager organization billing not found.");
|
||||
}
|
||||
if (managerOrgBilling.plan !== PlatformPlan.SCALE) {
|
||||
throw new NotFoundException("Manager organization billing plan is not SCALE.");
|
||||
if (
|
||||
!hasMinimumPlan({
|
||||
currentPlan: managerOrgBilling.plan as PlatformPlan,
|
||||
minimumPlan: PlatformPlan.SCALE,
|
||||
plans: orderedPlans,
|
||||
})
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
`organization with id=${managerOrgId} does not have required plan for this operation. Minimum plan is ${
|
||||
PlatformPlan.SCALE
|
||||
} while the organization has ${managerOrgBilling.plan || "undefined"}.`
|
||||
);
|
||||
}
|
||||
|
||||
return this.dbWrite.prisma.platformBilling.create({
|
||||
|
||||
+109
-4
@@ -705,6 +705,111 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe("managed user time zone", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not allow '' time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever2@gmail.com",
|
||||
timeZone: "",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not allow 'invalid-timezone' time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever2@gmail.com",
|
||||
timeZone: "invalid-timezone",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
it("should allow null timezone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever1@gmail.com",
|
||||
timeZone: null,
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it("should allow undefined time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever3@gmail.com",
|
||||
timeZone: undefined,
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it("should allow valid time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever4@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toBe("Europe/Rome");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
|
||||
it("should allow without any time zone", async () => {
|
||||
const requestBody = {
|
||||
email: "whatever5@gmail.com",
|
||||
name: "Bob Smithson",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
|
||||
await userRepositoryFixture.delete(responseBody.data.user.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await oauthClientRepositoryFixture.delete(oAuthClientEventTypesDisabled.id);
|
||||
@@ -712,17 +817,17 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData2.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(platformAdmin.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -931,7 +1036,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
try {
|
||||
await userRepositoryFixture.delete(postResponseData.user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -46,8 +46,8 @@ import { OrganizationsStripeService } from "@/modules/organizations/stripe/servi
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
@@ -76,7 +76,8 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
|
||||
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
|
||||
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
|
||||
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
|
||||
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
|
||||
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
|
||||
@@ -145,7 +146,8 @@ import { Module } from "@nestjs/common";
|
||||
TokensRepository,
|
||||
TeamsVerifiedResourcesRepository,
|
||||
WorkflowsRepository,
|
||||
TeamWorkflowsService,
|
||||
TeamEventTypeWorkflowsService,
|
||||
TeamRoutingFormWorkflowsService,
|
||||
WorkflowsInputService,
|
||||
WorkflowsOutputService,
|
||||
TeamsSchedulesService,
|
||||
|
||||
+132
-45
@@ -61,11 +61,24 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let managerOrg: Team;
|
||||
let payPerUserPlanManagerOrg: Team;
|
||||
let essentialsPlanManagerOrg: Team;
|
||||
|
||||
let managedOrg: ManagedOrganizationWithApiKeyOutput;
|
||||
let managedOrg2: ManagedOrganizationWithApiKeyOutput;
|
||||
|
||||
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
|
||||
const payPerUserPlanManagerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
|
||||
const essentialsPlanManagerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
|
||||
|
||||
let managerOrgAdmin: User;
|
||||
let payPerUserPlanManagerOrgAdmin: User;
|
||||
let essentialsPlanManagerOrgAdmin: User;
|
||||
|
||||
let managerOrgAdminApiKey: string;
|
||||
let payPerUserPlanManagerOrgAdminApiKey: string;
|
||||
let essentialsPlanManagerOrgAdminApiKey: string;
|
||||
|
||||
let managerOrgBilling: PlatformBilling;
|
||||
|
||||
let managedOrgApiKey: string;
|
||||
@@ -93,40 +106,36 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
managerOrgAdmin = await userRepositoryFixture.create({
|
||||
email: managerOrgAdminEmail,
|
||||
username: managerOrgAdminEmail,
|
||||
});
|
||||
|
||||
managerOrg = await organizationsRepositoryFixture.create({
|
||||
name: `organizations-organizations-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
});
|
||||
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: managerOrgAdminEmail,
|
||||
user: { connect: { id: managerOrgAdmin.id } },
|
||||
organization: { connect: { id: managerOrg.id } },
|
||||
movedFromUser: { connect: { id: managerOrgAdmin.id } },
|
||||
});
|
||||
|
||||
managerOrgBilling = await platformBillingRepositoryFixture.create(managerOrg.id, "SCALE");
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: managerOrgAdmin.id } },
|
||||
team: { connect: { id: managerOrg.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
managerOrgAdmin.id,
|
||||
null,
|
||||
managerOrg.id
|
||||
// Setup manager organization with SCALE plan
|
||||
const managerSetup = await setupTestOrganization(
|
||||
managerOrgAdminEmail,
|
||||
`organizations-organizations-organization-${randomString()}`,
|
||||
"SCALE"
|
||||
);
|
||||
managerOrgAdminApiKey = `cal_test_${keyString}`;
|
||||
managerOrgAdmin = managerSetup.admin;
|
||||
managerOrg = managerSetup.org;
|
||||
managerOrgBilling = managerSetup.billing;
|
||||
managerOrgAdminApiKey = managerSetup.apiKey;
|
||||
|
||||
// Setup pay-per-user organization
|
||||
const payPerUserSetup = await setupTestOrganization(
|
||||
payPerUserPlanManagerOrgAdminEmail,
|
||||
`pay-per-user-plan-organization-${randomString()}`,
|
||||
"PER_ACTIVE_USER"
|
||||
);
|
||||
payPerUserPlanManagerOrgAdmin = payPerUserSetup.admin;
|
||||
payPerUserPlanManagerOrg = payPerUserSetup.org;
|
||||
payPerUserPlanManagerOrgAdminApiKey = payPerUserSetup.apiKey;
|
||||
|
||||
// Setup essentials organization
|
||||
const essentialsSetup = await setupTestOrganization(
|
||||
essentialsPlanManagerOrgAdminEmail,
|
||||
`essentials-per-user-plan-organization-${randomString()}`,
|
||||
"ESSENTIALS"
|
||||
);
|
||||
essentialsPlanManagerOrgAdmin = essentialsSetup.admin;
|
||||
essentialsPlanManagerOrg = essentialsSetup.org;
|
||||
essentialsPlanManagerOrgAdminApiKey = essentialsSetup.apiKey;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
@@ -136,6 +145,57 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function setupTestOrganization(
|
||||
adminEmail: string,
|
||||
orgName: string,
|
||||
plan: "SCALE" | "PER_ACTIVE_USER" | "ESSENTIALS"
|
||||
) {
|
||||
const admin = await userRepositoryFixture.create({
|
||||
email: adminEmail,
|
||||
username: adminEmail,
|
||||
});
|
||||
|
||||
const org = await organizationsRepositoryFixture.create({
|
||||
name: orgName,
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
});
|
||||
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: `${randomString()}-uid`,
|
||||
username: adminEmail,
|
||||
user: { connect: { id: admin.id } },
|
||||
organization: { connect: { id: org.id } },
|
||||
movedFromUser: { connect: { id: admin.id } },
|
||||
});
|
||||
|
||||
const billing = await platformBillingRepositoryFixture.create(org.id, plan);
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: admin.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(admin.id, null, org.id);
|
||||
const apiKey = `cal_test_${keyString}`;
|
||||
|
||||
return { admin, org, billing, apiKey };
|
||||
}
|
||||
|
||||
function createManagedOrgInput(
|
||||
namePrefix: string,
|
||||
metadata?: Record<string, string | number | boolean>
|
||||
): CreateOrganizationInput {
|
||||
const suffix = randomString(5);
|
||||
return {
|
||||
name: `${namePrefix} ${suffix}`,
|
||||
slug: `${namePrefix.toLowerCase().replace(/\s+/g, "-")}-${suffix}`,
|
||||
metadata: metadata || { key: "value" },
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
clear();
|
||||
});
|
||||
@@ -155,21 +215,44 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
const suffix = randomString(5);
|
||||
const metadataKey = "first-org-metadata-key";
|
||||
const metadataValue = "first-org-metadata-value";
|
||||
const createManagedOrganizationBody: CreateOrganizationInput = {
|
||||
name: `org ${suffix}`,
|
||||
slug: `org-${suffix}`,
|
||||
metadata: { [metadataKey]: metadataValue },
|
||||
};
|
||||
const createManagedOrganizationBody: CreateOrganizationInput = createManagedOrgInput("org", {
|
||||
[metadataKey]: metadataValue,
|
||||
});
|
||||
|
||||
const suffix2 = randomString(5);
|
||||
const createManagedOrganizationBodySecond: CreateOrganizationInput = {
|
||||
name: `org2 ${suffix2}`,
|
||||
slug: `org2-${suffix2}`,
|
||||
metadata: { key: "value" },
|
||||
};
|
||||
const createManagedOrganizationBodySecond: CreateOrganizationInput = createManagedOrgInput("org2");
|
||||
|
||||
const createManagedOrganizationBodyThird: CreateOrganizationInput = createManagedOrgInput("org3");
|
||||
|
||||
it("should not allow to create managed organization if plan is below SCALE", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${essentialsPlanManagerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${essentialsPlanManagerOrgAdminApiKey}`)
|
||||
.send(createManagedOrganizationBody)
|
||||
.expect(403);
|
||||
expect(response.body.error.message).toBe(
|
||||
`PlatformPlanGuard - organization with id=${essentialsPlanManagerOrg.id} does not have required plan for this operation. Minimum plan is SCALE while the organization has ESSENTIALS.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow to create managed organization if plan is SCALE or above", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${payPerUserPlanManagerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${payPerUserPlanManagerOrgAdminApiKey}`)
|
||||
.send(createManagedOrganizationBodyThird)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const managedOrg3 = responseBody.data;
|
||||
expect(managedOrg3?.id).toBeDefined();
|
||||
expect(managedOrg3?.name).toEqual(createManagedOrganizationBodyThird.name);
|
||||
expect(managedOrg3?.slug).toEqual(createManagedOrganizationBodyThird.slug);
|
||||
expect(managedOrg3?.metadata).toEqual(createManagedOrganizationBodyThird.metadata);
|
||||
expect(managedOrg3?.apiKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should create managed organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
@@ -625,7 +708,11 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(managerOrgAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(payPerUserPlanManagerOrgAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(essentialsPlanManagerOrgAdmin.email);
|
||||
await organizationsRepositoryFixture.delete(managerOrg.id);
|
||||
await organizationsRepositoryFixture.delete(payPerUserPlanManagerOrg.id);
|
||||
await organizationsRepositoryFixture.delete(essentialsPlanManagerOrg.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+555
-34
@@ -4,22 +4,44 @@ import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import {
|
||||
CreateEventTypeWorkflowDto,
|
||||
WorkflowActivationDto,
|
||||
} from "@/modules/workflows/inputs/create-event-type-workflow.input";
|
||||
import {
|
||||
CreateFormWorkflowDto,
|
||||
WorkflowFormActivationDto,
|
||||
} from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import {
|
||||
ATTENDEE,
|
||||
REMINDER,
|
||||
PHONE_NUMBER,
|
||||
EMAIL,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
} from "@/modules/workflows/inputs/workflow-step.input";
|
||||
import {
|
||||
AFTER_EVENT,
|
||||
BEFORE_EVENT,
|
||||
DAY,
|
||||
FORM_SUBMITTED,
|
||||
FORM_SUBMITTED_NO_EVENT,
|
||||
OnAfterEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
OnFormSubmittedTriggerDto,
|
||||
} from "@/modules/workflows/inputs/workflow-trigger.input";
|
||||
import {
|
||||
GetEventTypeWorkflowOutput,
|
||||
GetEventTypeWorkflowsOutput,
|
||||
} from "@/modules/workflows/outputs/event-type-workflow.output";
|
||||
// Adjust path if needed
|
||||
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
|
||||
import {
|
||||
GetRoutingFormWorkflowOutput,
|
||||
GetRoutingFormWorkflowsOutput,
|
||||
} from "@/modules/workflows/outputs/routing-form-workflow.output";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
@@ -51,14 +73,21 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let createdWorkflowId: number;
|
||||
let createdFormWorkflowId: number;
|
||||
const authEmail = `org-teams-workflows-user-${randomString()}@example.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
let verifiedPhoneId: number;
|
||||
let verifiedPhoneId2: number;
|
||||
let verifiedEmailId: number;
|
||||
let createdWorkflow: GetWorkflowOutput["data"];
|
||||
let verifiedEmailId2: number;
|
||||
let createdWorkflow: GetEventTypeWorkflowOutput["data"];
|
||||
let createdFormWorkflow: GetRoutingFormWorkflowOutput["data"];
|
||||
|
||||
let sampleCreateWorkflowDto: CreateWorkflowDto = {
|
||||
const emailToVerify = `org-teams-workflows-team-${randomString()}@example.com`;
|
||||
const phoneToVerify = `+37255556666`;
|
||||
|
||||
let sampleCreateEventTypeWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllEventTypes: true,
|
||||
@@ -72,6 +101,18 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
},
|
||||
},
|
||||
steps: [],
|
||||
} as unknown as CreateEventTypeWorkflowDto;
|
||||
|
||||
let sampleCreateWorkflowRoutingFormDto: CreateFormWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
activeOnRoutingFormIds: [],
|
||||
isActiveOnAllRoutingForms: true,
|
||||
},
|
||||
trigger: {
|
||||
type: FORM_SUBMITTED,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -149,15 +190,29 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
phoneNumber: "+37255555555",
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
const verifiedPhone2 = await verifiedResourcesRepositoryFixtures.createPhone({
|
||||
user: { connect: { id: user.id } },
|
||||
phoneNumber: phoneToVerify,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
const verifiedEmail = await verifiedResourcesRepositoryFixtures.createEmail({
|
||||
user: { connect: { id: user.id } },
|
||||
email: authEmail,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
verifiedEmailId = verifiedEmail.id;
|
||||
verifiedPhoneId = verifiedPhone.id;
|
||||
|
||||
sampleCreateWorkflowDto = {
|
||||
const verifiedEmail2 = await verifiedResourcesRepositoryFixtures.createEmail({
|
||||
user: { connect: { id: user.id } },
|
||||
email: emailToVerify,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
verifiedEmailId = verifiedEmail.id;
|
||||
verifiedEmailId2 = verifiedEmail2.id;
|
||||
|
||||
verifiedPhoneId = verifiedPhone.id;
|
||||
verifiedPhoneId2 = verifiedPhone2.id;
|
||||
|
||||
sampleCreateEventTypeWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllEventTypes: true,
|
||||
@@ -208,6 +263,43 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
{
|
||||
stepNumber: 4,
|
||||
action: "sms_attendee",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
phoneRequired: true,
|
||||
sender: "CalcomE2EStep4",
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
text: "Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sampleCreateWorkflowRoutingFormDto = {
|
||||
name: `E2E Test Form Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllRoutingForms: true,
|
||||
activeOnRoutingFormIds: [],
|
||||
},
|
||||
trigger: {
|
||||
type: FORM_SUBMITTED,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
basePath = `/v2/organizations/${org.id}/teams/${orgTeam.id}/workflows`;
|
||||
@@ -220,7 +312,9 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
if (createdWorkflowId) {
|
||||
try {
|
||||
await workflowsRepositoryFixture.delete(createdWorkflowId);
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
@@ -235,18 +329,242 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(sampleCreateWorkflowDto)
|
||||
.send(sampleCreateEventTypeWorkflowDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowDto.name);
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
expect(responseBody.data.activation).toBeDefined();
|
||||
|
||||
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
|
||||
if (responseBody.data.activation instanceof WorkflowActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
);
|
||||
}
|
||||
if (
|
||||
responseBody.data.activation instanceof WorkflowFormActivationDto &&
|
||||
sampleCreateEventTypeWorkflowDto.activation instanceof WorkflowFormActivationDto
|
||||
) {
|
||||
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
|
||||
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllRoutingForms
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.id).toBeDefined();
|
||||
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.includeCalendarEvent).toEqual(
|
||||
true
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.sender).toEqual(
|
||||
"CalcomE2EStep2"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.phone).toEqual(
|
||||
"+37255555555"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.phoneRequired).toEqual(true);
|
||||
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
|
||||
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
|
||||
|
||||
createdWorkflowId = responseBody.data.id;
|
||||
createdWorkflow = responseBody.data;
|
||||
expect(responseBody.data.type).toEqual("event-type");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create a new routing form workflow with trigger not FORM_SUBMITTED", async () => {
|
||||
const invalidWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateEventTypeWorkflowDto;
|
||||
invalidWorkflow.trigger.type = AFTER_EVENT;
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(invalidWorkflow)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not create a new routing form workflow with not allowed actions", async () => {
|
||||
// force impossible step to test validation, should fail with 400
|
||||
const invalidWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateEventTypeWorkflowDto;
|
||||
invalidWorkflow.steps = [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "cal_ai_phone_call",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
verifiedPhoneId: verifiedPhoneId,
|
||||
sender: "CalcomE2EStep2",
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
text: "Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
} as unknown as WorkflowEmailAddressStepDto,
|
||||
];
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(invalidWorkflow)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create a new routing form workflow with allowed actions", async () => {
|
||||
const validWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateEventTypeWorkflowDto;
|
||||
validWorkflow.steps = [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
{
|
||||
stepNumber: 2,
|
||||
action: "sms_attendee",
|
||||
recipient: EMAIL,
|
||||
template: REMINDER,
|
||||
phoneRequired: false,
|
||||
sender: "updatedSender",
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
text: "Update Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
];
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(validWorkflow)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
|
||||
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
|
||||
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowRoutingFormDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(validWorkflow.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.action === "sms_attendee")).toBeDefined();
|
||||
|
||||
const trigger = sampleCreateWorkflowRoutingFormDto.trigger as OnFormSubmittedTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
|
||||
createdFormWorkflowId = responseBody.data.id;
|
||||
createdFormWorkflow = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new routing form workflow with allowed actions and offset trigger", async () => {
|
||||
const validWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateFormWorkflowDto;
|
||||
validWorkflow.steps = [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
validWorkflow.trigger = {
|
||||
type: FORM_SUBMITTED_NO_EVENT,
|
||||
offset: {
|
||||
value: 1,
|
||||
unit: DAY,
|
||||
},
|
||||
};
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(validWorkflow)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
|
||||
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
|
||||
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(validWorkflow.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
|
||||
const trigger = validWorkflow.trigger as OnFormSubmittedNoEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ ...sampleCreateEventTypeWorkflowDto, type: undefined })
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
|
||||
if (responseBody.data.activation instanceof WorkflowActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
|
||||
@@ -260,7 +578,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
"+37255555555"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
|
||||
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
|
||||
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
|
||||
|
||||
@@ -269,12 +587,23 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create a new workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
...sampleCreateEventTypeWorkflowDto,
|
||||
trigger: { ...sampleCreateEventTypeWorkflowDto, type: "formSubmitted" },
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
return request(app.getHttpServer()).post(basePath).send(sampleCreateWorkflowDto).expect(401);
|
||||
return request(app.getHttpServer()).post(basePath).send(sampleCreateEventTypeWorkflowDto).expect(401);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid data (e.g. missing name)", async () => {
|
||||
const invalidDto = { ...sampleCreateWorkflowDto, name: undefined };
|
||||
const invalidDto = { ...sampleCreateEventTypeWorkflowDto, name: undefined };
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
@@ -284,17 +613,33 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
|
||||
describe(`GET ${basePath}`, () => {
|
||||
it("should get a list of workflows for the team", async () => {
|
||||
it("should get a list of event-type workflows for the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}?skip=0&take=10`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowsOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeInstanceOf(Array);
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(responseBody.data.some((wf) => wf.id === createdWorkflowId)).toBe(true);
|
||||
expect(responseBody.data.every((wf) => wf.type === "event-type")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a list of routing-form workflows for the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}/routing-form?skip=0&take=10`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeInstanceOf(Array);
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(responseBody.data.some((wf) => wf.id === createdFormWorkflowId)).toBe(true);
|
||||
expect(responseBody.data.every((wf) => wf.type === "routing-form")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,10 +656,26 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdWorkflowId);
|
||||
expect(responseBody.data.type).toEqual("event-type");
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a specific routing-form workflow by ID", async () => {
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}/${createdFormWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,10 +695,12 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
describe(`PATCH ${basePath}/:workflowId`, () => {
|
||||
const updatedName = `Updated Workflow Name ${randomString()}`;
|
||||
|
||||
it("should update an existing workflow, update the first step and discard other steps", async () => {
|
||||
const step1 = createdWorkflow.steps.find((step) => step.stepNumber === 1);
|
||||
expect(step1).toBeDefined();
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
it("should update an existing workflow, update the first and second step and discard other steps", async () => {
|
||||
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
|
||||
expect(step2).toBeDefined();
|
||||
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
|
||||
expect(step3).toBeDefined();
|
||||
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
|
||||
name: updatedName,
|
||||
trigger: {
|
||||
type: "afterEvent",
|
||||
@@ -346,7 +709,38 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
|
||||
steps:
|
||||
step3 && step2
|
||||
? [
|
||||
{
|
||||
stepNumber: 1,
|
||||
id: step3.id,
|
||||
action: "email_address",
|
||||
recipient: EMAIL,
|
||||
template: REMINDER,
|
||||
verifiedEmailId: verifiedEmailId2,
|
||||
sender: "updatedSender",
|
||||
includeCalendarEvent: false,
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
} as UpdateEmailAddressWorkflowStepDto,
|
||||
{
|
||||
stepNumber: 2,
|
||||
id: step2.id,
|
||||
action: "whatsapp_number",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
verifiedPhoneId: verifiedPhoneId2,
|
||||
sender: "updatedSender",
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
text: "Update Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
} as UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
]
|
||||
: [],
|
||||
};
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
@@ -356,14 +750,32 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
.send(partialUpdateDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdWorkflowId);
|
||||
expect(responseBody.data.name).toEqual(updatedName);
|
||||
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
|
||||
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
|
||||
expect(responseBody.data.steps[1]?.id).toBeUndefined();
|
||||
if (step3) {
|
||||
const newStep3 = responseBody.data.steps.find((step) => step.id === step3.id);
|
||||
expect(newStep3).toBeDefined();
|
||||
if (newStep3) {
|
||||
expect(newStep3.sender).toEqual("updatedSender");
|
||||
expect(newStep3.email).toEqual(emailToVerify);
|
||||
expect(newStep3.includeCalendarEvent).toEqual(false);
|
||||
}
|
||||
}
|
||||
if (step2) {
|
||||
const newStep2 = responseBody.data.steps.find((step) => step.id === step2.id);
|
||||
expect(newStep2).toBeDefined();
|
||||
if (newStep2) {
|
||||
expect(responseBody.data.steps[1].sender).toEqual("updatedSender");
|
||||
expect(responseBody.data.steps[1].phone).toEqual(phoneToVerify);
|
||||
}
|
||||
}
|
||||
|
||||
// we updated 2 steps, third one should have been discarded
|
||||
expect(responseBody.data.steps[2]?.id).toBeUndefined();
|
||||
|
||||
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
@@ -371,8 +783,107 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update an existing event-type workflow, trying to use form workflow trigger", async () => {
|
||||
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
|
||||
expect(step2).toBeDefined();
|
||||
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
|
||||
expect(step3).toBeDefined();
|
||||
const partialUpdateDto = {
|
||||
name: updatedName,
|
||||
trigger: {
|
||||
type: "formSubmitted",
|
||||
offset: {
|
||||
unit: "minute",
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdWorkflowId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not update an existing event-type workflow, trying to use routing-form workflow endpoint", async () => {
|
||||
const partialUpdateDto = {
|
||||
name: updatedName,
|
||||
};
|
||||
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should update an existing routing form workflow, update the first step and discard any other steps", async () => {
|
||||
const step1 = createdFormWorkflow.steps.find((step) => step.stepNumber === 1);
|
||||
expect(step1).toBeDefined();
|
||||
|
||||
const partialUpdateDto: Partial<CreateFormWorkflowDto> = {
|
||||
name: updatedName,
|
||||
trigger: {
|
||||
type: "formSubmitted",
|
||||
},
|
||||
steps: step1
|
||||
? [
|
||||
{
|
||||
stepNumber: 1,
|
||||
id: step1.id,
|
||||
action: "email_address",
|
||||
recipient: EMAIL,
|
||||
template: REMINDER,
|
||||
verifiedEmailId: verifiedEmailId2,
|
||||
sender: "updatedSender",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
} as UpdateEmailAddressWorkflowStepDto,
|
||||
]
|
||||
: [],
|
||||
};
|
||||
expect(createdFormWorkflowId).toBeDefined();
|
||||
expect(createdFormWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdFormWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
|
||||
expect(responseBody.data.name).toEqual(updatedName);
|
||||
expect(responseBody.data.activation).toBeDefined();
|
||||
if (step1) {
|
||||
const newStep1 = responseBody.data.steps.find((step) => step.id === step1.id);
|
||||
expect(newStep1).toBeDefined();
|
||||
if (newStep1) {
|
||||
expect(newStep1.sender).toEqual("updatedSender");
|
||||
expect(newStep1.email).toEqual(emailToVerify);
|
||||
expect(newStep1.includeCalendarEvent).toEqual(true);
|
||||
}
|
||||
}
|
||||
|
||||
// we updated 1 steps, no more steps should be defined
|
||||
expect(responseBody.data.steps[1]?.id).toBeUndefined();
|
||||
const trigger = partialUpdateDto.trigger as OnFormSubmittedTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 for updating a non-existent workflow ID", async () => {
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
|
||||
};
|
||||
@@ -384,7 +895,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
|
||||
};
|
||||
@@ -403,11 +914,11 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ ...sampleCreateWorkflowDto, name: `Workflow To Delete ${randomString()}` });
|
||||
.send({ ...sampleCreateEventTypeWorkflowDto, name: `Workflow To Delete ${randomString()}` });
|
||||
workflowToDeleteId = res.body.data.id;
|
||||
});
|
||||
|
||||
it("should delete an existing workflow", async () => {
|
||||
it("should delete an existing event-type workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/${workflowToDeleteId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
@@ -417,6 +928,16 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete an existing routing-form workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/${createdFormWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when trying to delete a non-existent workflow ID", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/999999`)
|
||||
|
||||
+120
-22
@@ -13,12 +13,23 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { IsWorkflowInTeam } from "@/modules/auth/guards/workflows/is-workflow-in-team";
|
||||
import { IsEventTypeWorkflowInTeam } from "@/modules/auth/guards/workflows/is-event-type-workflow-in-team";
|
||||
import { IsRoutingFormWorkflowInTeam } from "@/modules/auth/guards/workflows/is-routing-form-workflow-in-team";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
|
||||
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
|
||||
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
|
||||
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
|
||||
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
|
||||
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
|
||||
import {
|
||||
GetEventTypeWorkflowsOutput,
|
||||
GetEventTypeWorkflowOutput,
|
||||
} from "@/modules/workflows/outputs/event-type-workflow.output";
|
||||
import {
|
||||
GetRoutingFormWorkflowOutput,
|
||||
GetRoutingFormWorkflowsOutput,
|
||||
} from "@/modules/workflows/outputs/routing-form-workflow.output";
|
||||
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
|
||||
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -46,7 +57,10 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationTeamWorkflowsController {
|
||||
constructor(private readonly workflowsService: TeamWorkflowsService) {}
|
||||
constructor(
|
||||
private readonly eventTypeWorkflowsService: TeamEventTypeWorkflowsService,
|
||||
private readonly routingFormWorkflowsService: TeamRoutingFormWorkflowsService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team workflows" })
|
||||
@@ -56,43 +70,89 @@ export class OrganizationTeamWorkflowsController {
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetWorkflowsOutput> {
|
||||
): Promise<GetEventTypeWorkflowsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const workflows = await this.workflowsService.getTeamWorkflows(teamId, skip, take);
|
||||
const workflows = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return { data: workflows, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/routing-form")
|
||||
@ApiOperation({ summary: "Get organization team workflows" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getRoutingFormWorkflows(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetRoutingFormWorkflowsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const workflows = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return { data: workflows, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@UseGuards(IsEventTypeWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Get organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getWorkflowById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.getTeamWorkflowById(teamId, workflowId);
|
||||
): Promise<GetEventTypeWorkflowOutput> {
|
||||
const workflow = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/:workflowId/routing-form")
|
||||
@UseGuards(IsRoutingFormWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Get organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getRoutingFormWorkflowById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number
|
||||
): Promise<GetRoutingFormWorkflowOutput> {
|
||||
const workflow = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflowById(
|
||||
teamId,
|
||||
workflowId
|
||||
);
|
||||
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create organization team workflow" })
|
||||
@ApiOperation({ summary: "Create organization team workflow for event-types" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async createWorkflow(
|
||||
async createEventTypeWorkflow(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() data: CreateWorkflowDto
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.createTeamWorkflow(user, teamId, data);
|
||||
@Body() data: CreateEventTypeWorkflowDto
|
||||
): Promise<GetEventTypeWorkflowOutput> {
|
||||
const workflow = await this.eventTypeWorkflowsService.createEventTypeTeamWorkflow(user, teamId, data);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Post("/routing-form")
|
||||
@ApiOperation({ summary: "Create organization team workflow for routing-forms" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async createFormWorkflow(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() data: CreateFormWorkflowDto
|
||||
): Promise<GetRoutingFormWorkflowOutput> {
|
||||
const workflow = await this.routingFormWorkflowsService.createFormTeamWorkflow(user, teamId, data);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Patch("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@UseGuards(IsEventTypeWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Update organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@@ -100,14 +160,39 @@ export class OrganizationTeamWorkflowsController {
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() data: UpdateWorkflowDto
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.updateTeamWorkflow(user, teamId, workflowId, data);
|
||||
@Body() data: UpdateEventTypeWorkflowDto
|
||||
): Promise<GetEventTypeWorkflowOutput> {
|
||||
const workflow = await this.eventTypeWorkflowsService.updateEventTypeTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
data
|
||||
);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Patch("/:workflowId/routing-form")
|
||||
@UseGuards(IsRoutingFormWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Update organization routing form team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async updateRoutingFormWorkflow(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() data: UpdateFormWorkflowDto
|
||||
): Promise<GetRoutingFormWorkflowOutput> {
|
||||
const workflow = await this.routingFormWorkflowsService.updateFormTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
data
|
||||
);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Delete("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@UseGuards(IsEventTypeWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Delete organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@@ -115,7 +200,20 @@ export class OrganizationTeamWorkflowsController {
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId") workflowId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
await this.workflowsService.deleteTeamWorkflow(teamId, workflowId);
|
||||
await this.eventTypeWorkflowsService.deleteTeamEventTypeWorkflow(teamId, workflowId);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Delete("/:workflowId/routing-form")
|
||||
@UseGuards(IsRoutingFormWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Delete organization team routing-form workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async deleteRoutingFormWorkflow(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId") workflowId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
await this.routingFormWorkflowsService.deleteTeamRoutingFormWorkflow(teamId, workflowId);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
email: `organizations-users-new-member-${randomString()}@api.com`,
|
||||
bio,
|
||||
metadata,
|
||||
timeZone: "Europe/Rome",
|
||||
};
|
||||
|
||||
const emailSpy = jest
|
||||
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
expect(userData.email).toBe(newOrgUser.email);
|
||||
expect(userData.bio).toBe(newOrgUser.bio);
|
||||
expect(userData.metadata).toEqual(newOrgUser.metadata);
|
||||
expect(userData.timeZone).toBe(newOrgUser.timeZone);
|
||||
expect(emailSpy).toHaveBeenCalledWith({
|
||||
usernameOrEmail: newOrgUser.email,
|
||||
orgName: org.name,
|
||||
|
||||
+205
-1
@@ -25,7 +25,12 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CAL_API_VERSION_HEADER,
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_09_04,
|
||||
ERROR_STATUS,
|
||||
} from "@calcom/platform-constants";
|
||||
import type {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
|
||||
@@ -48,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
|
||||
let teammateApiKeyString: string;
|
||||
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
|
||||
let teammateTwoApiKeyString: string;
|
||||
|
||||
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
|
||||
let outsider: User;
|
||||
@@ -61,6 +67,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
let collectiveEventTypeSlug: string;
|
||||
let collectiveEventTypeWithoutHostsId: number;
|
||||
let roundRobinEventTypeId: number;
|
||||
let roundRobinEventTypeWithoutFixedHostsId: number;
|
||||
let roundRobinEventTypeWithFixedAndNonFixedHostsId: number;
|
||||
let collectiveBookingId: number;
|
||||
let roundRobinBookingId: number;
|
||||
let fullyBookedRoundRobinBookingIdOne: number;
|
||||
@@ -115,6 +123,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
|
||||
teammateApiKeyString = keyString;
|
||||
|
||||
const { keyString: keyStringForTeammateTwo } = await apiKeysRepositoryFixture.createApiKey(
|
||||
teammateTwo.id,
|
||||
null
|
||||
);
|
||||
teammateTwoApiKeyString = keyStringForTeammateTwo;
|
||||
|
||||
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
outsider.id,
|
||||
null
|
||||
@@ -217,6 +231,67 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
roundRobinEventTypeId = roundRobinEventType.id;
|
||||
|
||||
const roundRobinEventTypeWithoutFixedHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "RR Event Type Without Fixed Hosts",
|
||||
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teammateOne.id,
|
||||
isFixed: false,
|
||||
},
|
||||
{
|
||||
userId: teammateTwo.id,
|
||||
isFixed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
|
||||
|
||||
const roundRobinEventTypeWithFixedAndNonFixedHosts =
|
||||
await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "RR Event Type With Fixed and Non-Fixed Hosts",
|
||||
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teammateOne.id,
|
||||
isFixed: true,
|
||||
},
|
||||
{
|
||||
userId: teammateTwo.id,
|
||||
isFixed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
@@ -541,6 +616,135 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
bookingsRepositoryFixture.deleteById(bookingTwo.id);
|
||||
});
|
||||
|
||||
it("should reserve all available slots for round robin event type with non-fixed hosts", async () => {
|
||||
const now = "2049-09-05T12:00:00.000Z";
|
||||
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
|
||||
advanceTo(newDate);
|
||||
|
||||
const slotStartTime = "2050-09-05T10:00:00.000Z";
|
||||
|
||||
const reserveResponseOne = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
|
||||
.send({
|
||||
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
|
||||
slotStart: slotStartTime,
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
const reserveResponseOneBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
|
||||
expect(reserveResponseOneBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseOneBody.data;
|
||||
expect(responseReservedSlotOne.reservationUid).toBeDefined();
|
||||
if (!responseReservedSlotOne.reservationUid) {
|
||||
throw new Error("Reserved slot one uid is undefined");
|
||||
}
|
||||
|
||||
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
|
||||
expect(dbSlotOne).toBeDefined();
|
||||
if (dbSlotOne) {
|
||||
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
|
||||
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
|
||||
expect(dbReleaseAt).toEqual(expectedReleaseAt);
|
||||
}
|
||||
|
||||
const reserveResponseTwo = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
|
||||
.send({
|
||||
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
|
||||
slotStart: slotStartTime,
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
const reserveResponseTwoBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseTwo.body;
|
||||
expect(reserveResponseTwoBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlotTwo: ReserveSlotOutputData_2024_09_04 = reserveResponseTwoBody.data;
|
||||
expect(responseReservedSlotTwo.reservationUid).toBeDefined();
|
||||
if (!responseReservedSlotTwo.reservationUid) {
|
||||
throw new Error("Reserved slot two uid is undefined");
|
||||
}
|
||||
|
||||
const dbSlotTwo = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotTwo.reservationUid);
|
||||
expect(dbSlotTwo).toBeDefined();
|
||||
if (dbSlotTwo) {
|
||||
const dbReleaseAt = DateTime.fromJSDate(dbSlotTwo.releaseAt, { zone: "UTC" }).toISO();
|
||||
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
|
||||
expect(dbReleaseAt).toEqual(expectedReleaseAt);
|
||||
}
|
||||
|
||||
const reserveResponseThree = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
|
||||
.send({
|
||||
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
|
||||
slotStart: slotStartTime,
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
|
||||
|
||||
expect(reserveResponseThree.status).toEqual(403);
|
||||
expect(reserveResponseThree.body.status).toEqual(ERROR_STATUS);
|
||||
|
||||
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
|
||||
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotTwo.reservationUid);
|
||||
clear();
|
||||
});
|
||||
|
||||
it("should reserve available slot for round robin event type with fixed and non-fixed hosts and should not be able to reserve another slot", async () => {
|
||||
const now = "2049-09-05T12:00:00.000Z";
|
||||
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
|
||||
advanceTo(newDate);
|
||||
|
||||
const slotStartTime = "2050-09-05T10:00:00.000Z";
|
||||
|
||||
const reserveResponseOne = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
|
||||
.send({
|
||||
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
|
||||
slotStart: slotStartTime,
|
||||
reservationDuration: 10,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
const reserveResponseBodyOne: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
|
||||
expect(reserveResponseBodyOne.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseBodyOne.data;
|
||||
expect(responseReservedSlotOne.reservationUid).toBeDefined();
|
||||
if (!responseReservedSlotOne.reservationUid) {
|
||||
throw new Error("Reserved slot uid is undefined");
|
||||
}
|
||||
|
||||
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
|
||||
expect(dbSlotOne).toBeDefined();
|
||||
if (dbSlotOne) {
|
||||
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
|
||||
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
|
||||
expect(dbReleaseAt).toEqual(expectedReleaseAt);
|
||||
}
|
||||
|
||||
const reserveResponseTwo = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
|
||||
slotStart: slotStartTime,
|
||||
})
|
||||
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
|
||||
|
||||
expect(reserveResponseTwo.status).toEqual(422);
|
||||
expect(reserveResponseTwo.body.status).toEqual(ERROR_STATUS);
|
||||
|
||||
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
|
||||
clear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teammateOne.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
|
||||
|
||||
@@ -48,7 +48,7 @@ import { ApiResponse } from "@calcom/platform-types";
|
||||
@DocsTags("Slots")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_09_04}`,
|
||||
description: `Must be set to ${VERSION_2024_09_04}. If not set to this value, the endpoint will default to an older version.`,
|
||||
example: VERSION_2024_09_04,
|
||||
required: true,
|
||||
schema: {
|
||||
@@ -86,6 +86,8 @@ export class SlotsController_2024_09_04 {
|
||||
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
|
||||
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
- bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@ApiQuery({
|
||||
@@ -256,7 +258,10 @@ export class SlotsController_2024_09_04 {
|
||||
@ApiOperation({
|
||||
summary: "Reserve a slot",
|
||||
description: `Make a slot not available for others to book for a certain period of time. If you authenticate using oAuth credentials, api key or access token
|
||||
then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).`,
|
||||
then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@@ -278,6 +283,7 @@ export class SlotsController_2024_09_04 {
|
||||
@Get("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Get reserved slot",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
async getReservedSlot(@Param("uid") uid: string): Promise<GetReservedSlotOutput_2024_09_04> {
|
||||
const reservedSlot = await this.slotsService.getReservedSlot(uid);
|
||||
@@ -293,6 +299,7 @@ export class SlotsController_2024_09_04 {
|
||||
@Patch("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Update a reserved slot",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateReservedSlot(
|
||||
@@ -312,6 +319,7 @@ export class SlotsController_2024_09_04 {
|
||||
@Delete("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Delete a reserved slot",
|
||||
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@DocsResponse({
|
||||
|
||||
@@ -23,6 +23,8 @@ import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SlotFormat } from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { validateRoundRobinSlotAvailability } from "@calcom/platform-libraries/slots";
|
||||
import type {
|
||||
GetSlotsInput_2024_09_04,
|
||||
GetSlotsInputWithRouting_2024_09_04,
|
||||
@@ -147,13 +149,26 @@ export class SlotsService_2024_09_04 {
|
||||
}
|
||||
|
||||
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
|
||||
if (nonSeatedEventAlreadyBooked) {
|
||||
const isRoundRobinEvent = eventType.schedulingType === SchedulingType.ROUND_ROBIN;
|
||||
|
||||
if (nonSeatedEventAlreadyBooked && !isRoundRobinEvent) {
|
||||
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
|
||||
}
|
||||
|
||||
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
|
||||
if (isRoundRobinEvent) {
|
||||
try {
|
||||
await validateRoundRobinSlotAvailability(input.eventTypeId, startDate, endDate, eventType.hosts);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new UnprocessableEntityException(error?.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
|
||||
}
|
||||
|
||||
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
|
||||
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
|
||||
|
||||
if (eventType.userId) {
|
||||
const slot = await this.slotsRepository.createSlot(
|
||||
|
||||
+125
@@ -328,6 +328,131 @@ describe("Teams Memberships Endpoints", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
|
||||
});
|
||||
|
||||
it("should filter memberships by single email", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
|
||||
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
|
||||
expect(responseBody.data[0].role).toEqual("ADMIN");
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter memberships by multiple emails", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
|
||||
const emails = responseBody.data.map((membership) => membership.user.email);
|
||||
expect(emails).toContain(teamAdminEmail);
|
||||
expect(emails).toContain(teamMemberEmail);
|
||||
|
||||
const adminMembership = responseBody.data.find((m) => m.user.email === teamAdminEmail);
|
||||
const memberMembership = responseBody.data.find((m) => m.user.email === teamMemberEmail);
|
||||
|
||||
expect(adminMembership).toBeDefined();
|
||||
expect(memberMembership).toBeDefined();
|
||||
expect(adminMembership?.role).toEqual("ADMIN");
|
||||
expect(memberMembership?.role).toEqual("MEMBER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when filtering by non-existent email", async () => {
|
||||
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${nonExistentEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return partial results when filtering by mix of existing and non-existent emails", async () => {
|
||||
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${nonExistentEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with pagination and email filtering combined", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}&skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
const returnedEmail = responseBody.data[0].user.email;
|
||||
expect([teamAdminEmail, teamMemberEmail]).toContain(returnedEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty emails array gracefully", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle URL encoded email addresses in filter", async () => {
|
||||
const encodedEmail = encodeURIComponent(teamAdminEmail);
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${encodedEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by email and maintain all user properties", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${teamMemberEmail}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
const membership = responseBody.data[0];
|
||||
expect(membership.user.email).toEqual(teamMemberEmail);
|
||||
expect(membership.user.bio).toEqual(teamMember.bio);
|
||||
expect(membership.user.metadata).toEqual(teamMember.metadata);
|
||||
expect(membership.user.username).toEqual(teamMember.username);
|
||||
expect(membership.teamId).toEqual(team.id);
|
||||
expect(membership.userId).toEqual(teamMember.id);
|
||||
expect(membership.role).toEqual("MEMBER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should validate email array size limits", async () => {
|
||||
const tooManyEmails = Array.from({ length: 21 }, (_, i) => `test${i}@example.com`).join(",");
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?emails=${tooManyEmails}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
|
||||
|
||||
+8
-4
@@ -4,6 +4,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { GetTeamMembershipsInput } from "@/modules/teams/memberships/inputs/get-team-memberships.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
|
||||
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
|
||||
@@ -32,7 +33,6 @@ import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/teams/:teamId/memberships",
|
||||
@@ -85,16 +85,20 @@ export class TeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all memberships" })
|
||||
@ApiOperation({
|
||||
summary: "Get all memberships",
|
||||
description: "Retrieve team memberships with optional filtering by email addresses. Supports pagination.",
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getTeamMemberships(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@Query() queryParams: GetTeamMembershipsInput
|
||||
): Promise<GetTeamMembershipsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const { skip, take, emails } = queryParams;
|
||||
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
|
||||
teamId,
|
||||
emails,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
export class GetTeamMembershipsInput extends SkipTakePagination {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value == null) return undefined;
|
||||
const rawValues = (Array.isArray(value) ? value : [value]).flatMap((entry) =>
|
||||
typeof entry === "string" ? entry.split(",") : []
|
||||
);
|
||||
const normalized = rawValues
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email.length > 0)
|
||||
.map((email) => email.toLowerCase());
|
||||
const deduplicated = [...new Set(normalized)];
|
||||
return deduplicated.length > 0 ? deduplicated : undefined;
|
||||
})
|
||||
@ArrayNotEmpty({ message: "emails cannot be empty." })
|
||||
@ArrayMaxSize(20, {
|
||||
message: "emails array cannot contain more than 20 email addresses for team membership filtering",
|
||||
})
|
||||
@IsEmail({}, { each: true, message: "Each email must be a valid email address" })
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description:
|
||||
"Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).",
|
||||
example: "?emails=user1@example.com,user2@example.com",
|
||||
})
|
||||
emails?: string[];
|
||||
}
|
||||
@@ -23,13 +23,15 @@ export class TeamsMembershipsService {
|
||||
return teamMembership;
|
||||
}
|
||||
|
||||
async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) {
|
||||
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated(
|
||||
async getPaginatedTeamMemberships(teamId: number, emails?: string[], skip = 0, take = 250) {
|
||||
const emailArray = !emails ? [] : emails;
|
||||
|
||||
return await this.teamsMembershipsRepository.findTeamMembershipsPaginatedWithFilters(
|
||||
teamId,
|
||||
{ emails: emailArray },
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return teamMemberships;
|
||||
}
|
||||
|
||||
async getTeamMembership(teamId: number, membershipId: number) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export interface TeamMembershipFilters {
|
||||
emails?: string[];
|
||||
}
|
||||
|
||||
export const MembershipUserSelect: Prisma.UserSelect = {
|
||||
username: true,
|
||||
email: true,
|
||||
@@ -41,6 +45,30 @@ export class TeamsMembershipsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamMembershipsPaginatedWithFilters(
|
||||
teamId: number,
|
||||
filters: TeamMembershipFilters,
|
||||
skip: number,
|
||||
take: number
|
||||
) {
|
||||
const whereClause: Prisma.MembershipWhereInput = {
|
||||
teamId: teamId,
|
||||
};
|
||||
|
||||
if (filters.emails && filters.emails.length > 0) {
|
||||
whereClause.user = {
|
||||
email: { in: filters.emails },
|
||||
};
|
||||
}
|
||||
|
||||
return await this.dbRead.prisma.membership.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamMembership(teamId: number, membershipId: number) {
|
||||
return this.dbRead.prisma.membership.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -120,30 +120,4 @@ export class TeamsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamMemberEmails(teamId: number) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
secondaryEmails: {
|
||||
where: {
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
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";
|
||||
|
||||
@@ -16,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" })
|
||||
@@ -29,6 +32,7 @@ export class CreateManagedUserInput {
|
||||
})
|
||||
weekStart?: WeekDay;
|
||||
|
||||
@Transform(({ value }) => (value === null ? undefined : value))
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
@CapitalizeTimeZone()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ export class CreateUserInput {
|
||||
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@CapitalizeTimeZone()
|
||||
@Validate(TimeZoneValidator)
|
||||
@Expose()
|
||||
timeZone?: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ export class GetUsersInput {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: "The email address or an array of email addresses to filter by",
|
||||
example: ["user1@example.com", "user2@example.com"],
|
||||
})
|
||||
emails?: string[];
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -408,4 +408,30 @@ export class UsersRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEmailsVerifiedForTeam(teamId: number) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
secondaryEmails: {
|
||||
where: {
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user