Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae87fdcc9f | ||
|
|
35e29c4bee | ||
|
|
387a1704f3 | ||
|
|
3fb8c5bbc5 | ||
|
|
626585f5d0 | ||
|
|
4d3727db12 | ||
|
|
f6641d5173 | ||
|
|
f9b21206a2 | ||
|
|
96821c0026 | ||
|
|
3d5e4754af | ||
|
|
613e9f0788 | ||
|
|
1261247e94 | ||
|
|
2ad843ffbb | ||
|
|
6923b97cd2 | ||
|
|
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 |
+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
|
||||
|
||||
@@ -267,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
|
||||
|
||||
@@ -479,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
|
||||
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
||||
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
|
||||
- run: USE_POOL=true yarn db-seed
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
- name: Postgres Dump Backup
|
||||
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
yarn workspace @calcom/api-v2 run generate-schemas
|
||||
rm -rf apps/api/v2/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/api-v2 run generate-schemas &&
|
||||
rm -rf apps/api/v2/node_modules &&
|
||||
yarn install &&
|
||||
yarn workspace @calcom/api-v2 run build
|
||||
shell: bash
|
||||
|
||||
@@ -16,7 +16,6 @@ env:
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
|
||||
@@ -10,7 +10,6 @@ env:
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
@@ -36,7 +35,6 @@ env:
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Web App
|
||||
|
||||
@@ -67,6 +67,9 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# Prisma generated files
|
||||
packages/prisma/client/*
|
||||
!packages/prisma/client/index.ts
|
||||
apps/api/v2/generated/prisma/*
|
||||
packages/prisma/generated/prisma/*
|
||||
packages/prisma/zod/**/*.ts
|
||||
packages/prisma/enums
|
||||
|
||||
|
||||
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
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
@@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const getAllAdminMemberships = async (userId: number) => {
|
||||
where: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] },
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
@@ -56,16 +56,19 @@ export const getAccessibleUsers = async ({
|
||||
memberUserIds,
|
||||
adminUserId,
|
||||
}: AccessibleUsersType): Promise<number[]> => {
|
||||
const orConditions = [];
|
||||
if (memberUserIds.length > 0) {
|
||||
orConditions.push({ userId: { in: memberUserIds } });
|
||||
}
|
||||
orConditions.push({ userId: adminUserId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } });
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
isOrganization: true,
|
||||
},
|
||||
accepted: true,
|
||||
OR: [
|
||||
{ userId: { in: memberUserIds } },
|
||||
{ userId: adminUserId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } },
|
||||
],
|
||||
OR: orConditions,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ async function authMiddleware(req: NextApiRequest) {
|
||||
if (isSystemWideAdmin) return;
|
||||
// Find all references where the user has bookings
|
||||
const bookingReference = await prisma.bookingReference.findFirst({
|
||||
where: { id, booking: { userId } },
|
||||
where: { id, booking: { userId }, deleted: null },
|
||||
});
|
||||
if (!bookingReference) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
export async function deleteHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
await prisma.bookingReference.delete({ where: { id } });
|
||||
await prisma.bookingReference.update({ where: { id }, data: { deleted: true } });
|
||||
return { message: `BookingReference with id: ${id} deleted` };
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const booking_reference = await prisma.bookingReference.findUniqueOrThrow({ where: { id } });
|
||||
const booking_reference = await prisma.bookingReference.findUniqueOrThrow({ where: { id, deleted: null } });
|
||||
return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) };
|
||||
}
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-refe
|
||||
* 404:
|
||||
* description: No booking references were found
|
||||
*/
|
||||
async function getHandler(req: NextApiRequest) {
|
||||
export async function handler(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin } = req;
|
||||
const args: Prisma.BookingReferenceFindManyArgs = isSystemWideAdmin
|
||||
? {}
|
||||
: { where: { booking: { userId } } };
|
||||
? { where: { deleted: null } }
|
||||
: { where: { booking: { userId }, deleted: null } };
|
||||
const data = await prisma.bookingReference.findMany(args);
|
||||
return { booking_references: data.map((br) => schemaBookingReferenceReadPublic.parse(br)) };
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -3,8 +3,8 @@ import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
@@ -48,7 +48,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
: availableSlots.slots;
|
||||
|
||||
return { slots: slotsInProvidedTimeZone };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Booking, Credential, EventType, User } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { patchHandler } from "../../pages/api/booking-references/[id]/_patch";
|
||||
import { handler } from "../../pages/api/booking-references/_get";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("GET /api/booking-references - Soft-delete filtering", () => {
|
||||
let testUser: User;
|
||||
let testCredential: Credential;
|
||||
let testEventType: EventType;
|
||||
let testBooking: Booking;
|
||||
const createdBookingReferenceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "api-bookingreference-test@example.com",
|
||||
username: "api-bookingreference-test",
|
||||
},
|
||||
});
|
||||
|
||||
testCredential = await prisma.credential.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
key: {},
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
testEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: "api-test-event-type",
|
||||
length: 30,
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
testBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: "api-test-booking-uid",
|
||||
title: "API Test Booking",
|
||||
startTime: new Date(Date.now() + 86400000),
|
||||
endTime: new Date(Date.now() + 90000000),
|
||||
userId: testUser.id,
|
||||
eventTypeId: testEventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdBookingReferenceIds.length > 0) {
|
||||
await prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdBookingReferenceIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.booking.delete({
|
||||
where: {
|
||||
id: testBooking.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventType.delete({
|
||||
where: {
|
||||
id: testEventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.credential.delete({
|
||||
where: {
|
||||
id: testCredential.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: testUser.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should only return active booking references, excluding soft-deleted ones", async () => {
|
||||
const timestamp = Date.now();
|
||||
const activeRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
uid: `api-active-ref-${timestamp}`,
|
||||
meetingId: `api-active-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(activeRef.id);
|
||||
|
||||
const deletedRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
uid: `api-deleted-ref-${timestamp}`,
|
||||
meetingId: `api-deleted-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
deleted: true,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(deletedRef.id);
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
|
||||
expect(responseData.booking_references).toBeDefined();
|
||||
expect(Array.isArray(responseData.booking_references)).toBe(true);
|
||||
|
||||
const returnedActiveRef = responseData.booking_references.find(
|
||||
(ref: { id: number }) => ref.id === activeRef.id
|
||||
);
|
||||
const returnedDeletedRef = responseData.booking_references.find(
|
||||
(ref: { id: number }) => ref.id === deletedRef.id
|
||||
);
|
||||
|
||||
expect(returnedActiveRef).toBeDefined();
|
||||
expect(returnedDeletedRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should filter booking references by user when not system admin", async () => {
|
||||
const timestamp = Date.now();
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `other-user-bookingreference-${timestamp}@example.com`,
|
||||
username: `other-user-bookingreference-${timestamp}`,
|
||||
},
|
||||
});
|
||||
|
||||
const otherBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `other-user-booking-uid-${timestamp}`,
|
||||
title: "Other User Booking",
|
||||
startTime: new Date(Date.now() + 86400000),
|
||||
endTime: new Date(Date.now() + 90000000),
|
||||
userId: otherUser.id,
|
||||
eventTypeId: testEventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
const otherUserRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "zoom_video",
|
||||
uid: `other-user-ref-${timestamp}`,
|
||||
meetingId: `other-user-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: otherBooking.id,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(otherUserRef.id);
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
|
||||
const userRefs = responseData.booking_references.filter(
|
||||
(ref: { id: number }) => ref.id === otherUserRef.id
|
||||
);
|
||||
|
||||
expect(userRefs).toHaveLength(0);
|
||||
|
||||
await prisma.booking.delete({ where: { id: otherBooking.id } });
|
||||
await prisma.user.delete({ where: { id: otherUser.id } });
|
||||
});
|
||||
|
||||
it("should return only active booking references for user, not soft-deleted ones for system admin", async () => {
|
||||
const timestamp = Date.now();
|
||||
const activeRef1 = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "daily_video",
|
||||
uid: `admin-active-ref-1-${timestamp}`,
|
||||
meetingId: `admin-active-meeting-1-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(activeRef1.id);
|
||||
|
||||
const activeRef2 = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "daily_video",
|
||||
uid: `admin-active-ref-2-${timestamp}`,
|
||||
meetingId: `admin-active-meeting-2-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(activeRef2.id);
|
||||
|
||||
const deletedRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "daily_video",
|
||||
uid: `admin-deleted-ref-${timestamp}`,
|
||||
meetingId: `admin-deleted-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
deleted: true,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(deletedRef.id);
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
|
||||
const activeRefs = responseData.booking_references.filter(
|
||||
(ref: { id: number }) => ref.id === activeRef1.id || ref.id === activeRef2.id
|
||||
);
|
||||
const returnedDeletedRef = responseData.booking_references.find(
|
||||
(ref: { id: number }) => ref.id === deletedRef.id
|
||||
);
|
||||
|
||||
expect(activeRefs.length).toBe(2);
|
||||
expect(returnedDeletedRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should verify soft-deleted references are never returned through the API", async () => {
|
||||
const timestamp = Date.now();
|
||||
const activeRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "office365_calendar",
|
||||
uid: `never-return-active-${timestamp}`,
|
||||
meetingId: `never-return-active-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(activeRef.id);
|
||||
|
||||
await prisma.bookingReference.update({
|
||||
where: { id: activeRef.id },
|
||||
data: { deleted: true },
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
|
||||
const responseData = await handler(req);
|
||||
|
||||
const softDeletedRef = responseData.booking_references.find(
|
||||
(ref: { id: number }) => ref.id === activeRef.id
|
||||
);
|
||||
|
||||
expect(softDeletedRef).toBeUndefined();
|
||||
|
||||
const refInDb = await prisma.bookingReference.findUnique({
|
||||
where: { id: activeRef.id },
|
||||
});
|
||||
expect(refInDb).toBeDefined();
|
||||
expect(refInDb?.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/booking-references/[id] - Existing functionality", () => {
|
||||
let testUser: User;
|
||||
let testCredential: Credential;
|
||||
let testEventType: EventType;
|
||||
let testBooking: Booking;
|
||||
const createdBookingReferenceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "api-bookingreference-patch-test@example.com",
|
||||
username: "api-bookingreference-patch-test",
|
||||
},
|
||||
});
|
||||
|
||||
testCredential = await prisma.credential.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
key: {},
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
testEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: "api-patch-test-event-type",
|
||||
length: 30,
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
testBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: "api-patch-test-booking-uid",
|
||||
title: "API Patch Test Booking",
|
||||
startTime: new Date(Date.now() + 86400000),
|
||||
endTime: new Date(Date.now() + 90000000),
|
||||
userId: testUser.id,
|
||||
eventTypeId: testEventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdBookingReferenceIds.length > 0) {
|
||||
await prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdBookingReferenceIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.booking.delete({
|
||||
where: {
|
||||
id: testBooking.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventType.delete({
|
||||
where: {
|
||||
id: testEventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.credential.delete({
|
||||
where: {
|
||||
id: testCredential.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: testUser.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully update an active booking reference", async () => {
|
||||
const timestamp = Date.now();
|
||||
const activeRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
uid: `patch-active-ref-${timestamp}`,
|
||||
meetingId: `patch-active-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(activeRef.id);
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "PATCH",
|
||||
query: { id: activeRef.id.toString() },
|
||||
body: {
|
||||
meetingPassword: "new-password-123",
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
req.isSystemWideAdmin = false;
|
||||
|
||||
const responseData = await patchHandler(req);
|
||||
|
||||
expect(responseData.booking_reference).toBeDefined();
|
||||
expect(responseData.booking_reference.id).toBe(activeRef.id);
|
||||
expect(responseData.booking_reference.meetingPassword).toBe("new-password-123");
|
||||
|
||||
const updatedRef = await prisma.bookingReference.findUnique({
|
||||
where: { id: activeRef.id },
|
||||
});
|
||||
expect(updatedRef?.meetingPassword).toBe("new-password-123");
|
||||
});
|
||||
|
||||
it("should allow updating a soft-deleted booking reference (existing functionality)", async () => {
|
||||
const timestamp = Date.now();
|
||||
const deletedRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
uid: `patch-deleted-ref-${timestamp}`,
|
||||
meetingId: `patch-deleted-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
deleted: true,
|
||||
meetingPassword: "original-password",
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(deletedRef.id);
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "PATCH",
|
||||
query: { id: deletedRef.id.toString() },
|
||||
body: {
|
||||
meetingPassword: "updated-password",
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
req.isSystemWideAdmin = false;
|
||||
|
||||
const responseData = await patchHandler(req);
|
||||
|
||||
expect(responseData.booking_reference).toBeDefined();
|
||||
expect(responseData.booking_reference.id).toBe(deletedRef.id);
|
||||
expect(responseData.booking_reference.meetingPassword).toBe("updated-password");
|
||||
|
||||
const updatedRef = await prisma.bookingReference.findUnique({
|
||||
where: { id: deletedRef.id },
|
||||
});
|
||||
expect(updatedRef?.meetingPassword).toBe("updated-password");
|
||||
expect(updatedRef?.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should verify that booking references can be updated regardless of deleted status", async () => {
|
||||
const timestamp = Date.now();
|
||||
const activeRef = await prisma.bookingReference.create({
|
||||
data: {
|
||||
type: "zoom_video",
|
||||
uid: `patch-verify-active-${timestamp}`,
|
||||
meetingId: `patch-verify-active-meeting-${timestamp}`,
|
||||
credentialId: testCredential.id,
|
||||
bookingId: testBooking.id,
|
||||
meetingPassword: "original-password",
|
||||
},
|
||||
});
|
||||
createdBookingReferenceIds.push(activeRef.id);
|
||||
|
||||
await prisma.bookingReference.update({
|
||||
where: { id: activeRef.id },
|
||||
data: { deleted: true },
|
||||
});
|
||||
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "PATCH",
|
||||
query: { id: activeRef.id.toString() },
|
||||
body: {
|
||||
meetingPassword: "updated-password",
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = testUser.id;
|
||||
req.isSystemWideAdmin = false;
|
||||
|
||||
const responseData = await patchHandler(req);
|
||||
|
||||
expect(responseData.booking_reference).toBeDefined();
|
||||
expect(responseData.booking_reference.meetingPassword).toBe("updated-password");
|
||||
|
||||
const refInDb = await prisma.bookingReference.findUnique({
|
||||
where: { id: activeRef.id },
|
||||
});
|
||||
expect(refInDb?.meetingPassword).toBe("updated-password");
|
||||
expect(refInDb?.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/app-store/videoClient";
|
||||
} from "@calcom/features/conferencing/lib/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
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(),
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/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,
|
||||
})),
|
||||
@@ -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({
|
||||
|
||||
@@ -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,38 +1,52 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { NextApiRequest } from "next";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||
|
||||
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",
|
||||
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 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 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 () => {
|
||||
@@ -46,6 +60,8 @@ describe("isLockedOrBlocked", () => {
|
||||
|
||||
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,8 +73,12 @@ describe("isLockedOrBlocked", () => {
|
||||
},
|
||||
} 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,8 +90,12 @@ describe("isLockedOrBlocked", () => {
|
||||
},
|
||||
} 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,7 +107,11 @@ describe("isLockedOrBlocked", () => {
|
||||
},
|
||||
} 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,4 +42,5 @@ lerna-debug.log*
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.appStore.example
|
||||
/client/*
|
||||
/client/*
|
||||
/generated/prisma/*
|
||||
|
||||
@@ -22,6 +22,10 @@ const loadConfig = (): AppConfig => {
|
||||
db: {
|
||||
readUrl: getEnv("DATABASE_READ_URL"),
|
||||
writeUrl: getEnv("DATABASE_WRITE_URL"),
|
||||
readPoolMax: getEnv("DATABASE_READ_POOL_MAX", 9),
|
||||
writePoolMax: getEnv("DATABASE_WRITE_POOL_MAX", 7),
|
||||
workerReadPoolMax: getEnv("DATABASE_READ_WORKER_POOL_MAX", 4),
|
||||
workerWritePoolMax: getEnv("DATABASE_WRITE_WORKER_POOL_MAX", 6),
|
||||
redisUrl: getEnv("REDIS_URL"),
|
||||
},
|
||||
next: {
|
||||
|
||||
@@ -13,6 +13,10 @@ export type AppConfig = {
|
||||
db: {
|
||||
readUrl: string;
|
||||
writeUrl: string;
|
||||
readPoolMax: number;
|
||||
writePoolMax: number;
|
||||
workerReadPoolMax: number;
|
||||
workerWritePoolMax: number;
|
||||
redisUrl: string;
|
||||
};
|
||||
next: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -9,7 +9,7 @@ export class BookingReferencesRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
|
||||
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
|
||||
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId, deleted: null };
|
||||
|
||||
if (filter?.type) {
|
||||
whereClause.type = filter.type;
|
||||
@@ -30,6 +30,7 @@ export class BookingReferencesRepository_2024_08_13 {
|
||||
return this.dbRead.prisma.bookingReference.findFirst({
|
||||
where: {
|
||||
uid: eventUid,
|
||||
deleted: null,
|
||||
},
|
||||
include: {
|
||||
credential: true,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
@@ -37,10 +40,8 @@ import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
handleNewRecurringBooking,
|
||||
getTranslation,
|
||||
getAllUserBookings,
|
||||
handleInstantMeeting,
|
||||
handleCancelBooking,
|
||||
roundRobinReassignment,
|
||||
roundRobinManualReassignment,
|
||||
@@ -48,7 +49,6 @@ import {
|
||||
confirmBookingHandler,
|
||||
getCalendarLinks,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateBookingInput,
|
||||
@@ -109,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) {
|
||||
@@ -464,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
|
||||
@@ -482,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);
|
||||
@@ -505,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 || "" })),
|
||||
@@ -528,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) {
|
||||
@@ -560,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) {
|
||||
@@ -755,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({
|
||||
|
||||
+182
-9
@@ -31,16 +31,18 @@ import {
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import type {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
SplitNameDefaultFieldOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
import {
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
type GuestsDefaultFieldOutput_2024_06_14,
|
||||
type NameDefaultFieldInput_2024_06_14,
|
||||
type NotesDefaultFieldInput_2024_06_14,
|
||||
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||
type UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
|
||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||
@@ -1442,6 +1444,177 @@ describe("Event types Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe("bookerActiveBookingsLimit", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not create an event type with bookerActiveBookingsLimit and recurrence", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit",
|
||||
slug: "coding-class-booker-active-bookings-limit",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with bookerActiveBookingsLimit disabled:false", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit disabled:false",
|
||||
slug: "coding-class-booker-active-bookings-limit-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
// note(Lauris): disabled false means that it is enabled so it should have maximumActiveBookings and / or offerReschedule provided
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message.includes(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
let eventTypeWithBookerActiveBookingsLimitId: number;
|
||||
|
||||
it("should create an event type with bookerActiveBookingsLimit", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit",
|
||||
slug: "coding-class-booker-active-bookings-limit",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with bookerActiveBookingsLimit", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithBookerActiveBookingsLimitId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
expect(updatedEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with bookerActiveBookingsLimit and recurrence disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit and recurrence disabled",
|
||||
slug: "coding-class-booker-active-bookings-limit-recurrence-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
recurrence: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with recurrence and bookerActiveBookingsLimit disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit disabled and recurrence",
|
||||
slug: "coding-class-booker-active-bookings-limit-disabled-and-recurrence",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+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
|
||||
|
||||
+3
-1
@@ -39,9 +39,11 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const { eventType: eventTypeCreated } = await createEventType({
|
||||
input: rest,
|
||||
ctx: {
|
||||
|
||||
+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
|
||||
|
||||
@@ -34,6 +34,10 @@ export type Environment = {
|
||||
IS_TEAM_BILLING_ENABLED: boolean;
|
||||
// Used to enable/disable the rewrite of /api/v2 to /v2, active by default.
|
||||
REWRITE_API_V2_PREFIX: string;
|
||||
DATABASE_READ_POOL_MAX: number;
|
||||
DATABASE_WRITE_POOL_MAX: number;
|
||||
DATABASE_READ_WORKER_POOL_MAX: number;
|
||||
DATABASE_WRITE_WORKER_POOL_MAX: number;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -16,12 +16,12 @@ import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { PrismaWorkerModule } from "@/modules/prisma/prisma-worker.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaWorkerModule],
|
||||
providers: [
|
||||
PrismaOOORepository,
|
||||
PrismaScheduleRepository,
|
||||
|
||||
@@ -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,13 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+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,6 +1,7 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import type { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { PlatformBilling, Team } from "@calcom/prisma/client";
|
||||
|
||||
export type BillingData = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MembershipsRepository {
|
||||
},
|
||||
});
|
||||
|
||||
return userOrgAdminMemberships.map((m) => m.teamId);
|
||||
return userOrgAdminMemberships.map((m: { teamId: number }) => m.teamId);
|
||||
}
|
||||
|
||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { MembershipsRepository } from "@/modules/memberships/memberships.reposit
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { intersectionBy } from "lodash";
|
||||
|
||||
import type { Membership } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class MembershipsService {
|
||||
constructor(private readonly membershipsRepository: MembershipsRepository) {}
|
||||
@@ -16,8 +18,8 @@ export class MembershipsService {
|
||||
const secondUserMemberships = await this.membershipsRepository.findUserMemberships(secondUserId);
|
||||
|
||||
return intersectionBy(
|
||||
firstUserMemberships.filter((m) => m.accepted),
|
||||
secondUserMemberships.filter((m) => m.accepted),
|
||||
firstUserMemberships.filter((m: Membership) => m.accepted),
|
||||
secondUserMemberships.filter((m: Membership) => m.accepted),
|
||||
"teamId"
|
||||
);
|
||||
}
|
||||
|
||||
+13
-14
@@ -434,20 +434,19 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.hosts).toEqual([
|
||||
{
|
||||
userId: teammate1.id,
|
||||
name: teammate1.name,
|
||||
username: teammate1.username,
|
||||
avatarUrl: teammate1.avatarUrl,
|
||||
},
|
||||
{
|
||||
userId: teammate2.id,
|
||||
name: teammate2.name,
|
||||
username: teammate2.username,
|
||||
avatarUrl: teammate2.avatarUrl,
|
||||
},
|
||||
]);
|
||||
expect(responseTeamEvent?.hosts?.find((host) => host.userId === teammate1.id)).toEqual({
|
||||
userId: teammate1.id,
|
||||
name: teammate1.name,
|
||||
username: teammate1.username,
|
||||
avatarUrl: teammate1.avatarUrl,
|
||||
});
|
||||
|
||||
expect(responseTeamEvent?.hosts?.find((host) => host.userId === teammate2.id)).toEqual({
|
||||
userId: teammate2.id,
|
||||
name: teammate2.name,
|
||||
username: teammate2.username,
|
||||
avatarUrl: teammate2.avatarUrl,
|
||||
});
|
||||
|
||||
if (!responseTeamEvent) {
|
||||
throw new Error("Team event not found");
|
||||
|
||||
+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();
|
||||
});
|
||||
});
|
||||
|
||||
-3
@@ -122,7 +122,6 @@ describe("Organizations Teams Invite Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
@@ -131,5 +130,3 @@ describe("Organizations Teams Invite Endpoints", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
+5
-5
@@ -1,5 +1,9 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
@@ -11,10 +15,8 @@ import { Controller, UseGuards, Post, Param, ParseIntPipe, HttpCode, HttpStatus
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
|
||||
|
||||
import { CreateInviteOutputDto } from "./outputs/invite.output";
|
||||
|
||||
@Controller({
|
||||
@@ -39,5 +41,3 @@ export class OrganizationsTeamsInviteController {
|
||||
return { status: SUCCESS_STATUS, data: result };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ export class InviteDataDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
|
||||
example: "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
|
||||
example:
|
||||
"http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
|
||||
})
|
||||
inviteLink!: string;
|
||||
}
|
||||
@@ -23,5 +24,3 @@ export class CreateInviteOutputDto {
|
||||
@ApiProperty({ type: InviteDataDto })
|
||||
data!: InviteDataDto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+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 };
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user