Compare commits
308
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31da30b982 | ||
|
|
d149f8385e | ||
|
|
ea5a0bd5f0 | ||
|
|
67c9fd92e1 | ||
|
|
50be997753 | ||
|
|
f00c14d0c1 | ||
|
|
8d4dd0241a | ||
|
|
90f97c3c92 | ||
|
|
1407886612 | ||
|
|
139e47fe3b | ||
|
|
9d4ecf2baa | ||
|
|
9d1cf3e70e | ||
|
|
6beb279a27 | ||
|
|
69e4b12f52 | ||
|
|
b8098ed3ab | ||
|
|
db55b11034 | ||
|
|
2273b7ea24 | ||
|
|
0a90212980 | ||
|
|
2efcfab715 | ||
|
|
3c1dfe8b74 | ||
|
|
e47ddf9835 | ||
|
|
08c62b554e | ||
|
|
b0b94b91fe | ||
|
|
af61b6d341 | ||
|
|
28693aba96 | ||
|
|
2dc0e2a6f2 | ||
|
|
6cbe1c1cf1 | ||
|
|
1c2da58d2a | ||
|
|
7bfdaa3d64 | ||
|
|
c38d2afcf5 | ||
|
|
807848cce2 | ||
|
|
e9d409c0d5 | ||
|
|
0c43d60039 | ||
|
|
1173822344 | ||
|
|
a934cec51a | ||
|
|
257acc3f71 | ||
|
|
91063c41f8 | ||
|
|
1e1298fdbc | ||
|
|
95120e1ff4 | ||
|
|
ee00f0d52c | ||
|
|
246abe0dd9 | ||
|
|
14e845ea1f | ||
|
|
8ba65c4450 | ||
|
|
aff494f21c | ||
|
|
18ad7e348e | ||
|
|
78f3d76ae2 | ||
|
|
6f9e04c6d9 | ||
|
|
fdb0e5d004 | ||
|
|
268a142370 | ||
|
|
29a3892669 | ||
|
|
0223027958 | ||
|
|
d7df154059 | ||
|
|
51d78e6f9d | ||
|
|
d5894d9fbc | ||
|
|
8b0539028e | ||
|
|
c7600d8ead | ||
|
|
6f5b8ead75 | ||
|
|
fc159fb9ea | ||
|
|
b3297f2843 | ||
|
|
1dade81299 | ||
|
|
b2839830c8 | ||
|
|
fa884e8516 | ||
|
|
3418a25a85 | ||
|
|
89cc175d73 | ||
|
|
c7c8564b63 | ||
|
|
b620581d7a | ||
|
|
c0530cc91c | ||
|
|
f738b3bc0a | ||
|
|
0853b4ca35 | ||
|
|
8be05b1dc5 | ||
|
|
71515ad17c | ||
|
|
66234ffba9 | ||
|
|
66c5f810ea | ||
|
|
81d9d84b4a | ||
|
|
6ddc666827 | ||
|
|
01346abb85 | ||
|
|
3d9650c841 | ||
|
|
f8ede75dd8 | ||
|
|
435587f043 | ||
|
|
e5abe93940 | ||
|
|
c3a5158a53 | ||
|
|
a9e14b9816 | ||
|
|
e207f40611 | ||
|
|
4ca2076c72 | ||
|
|
959b803d84 | ||
|
|
7f6136004c | ||
|
|
bb79368b2d | ||
|
|
70825dd138 | ||
|
|
05c399db9c | ||
|
|
57776b829b | ||
|
|
7fd28c39f3 | ||
|
|
ff17aa79fc | ||
|
|
578be32936 | ||
|
|
b183239ff5 | ||
|
|
013e614397 | ||
|
|
6843ced3db | ||
|
|
7318015a6e | ||
|
|
fc4d0a6aa3 | ||
|
|
8e0cdf3671 | ||
|
|
fcd9480f9f | ||
|
|
09ddbe886f | ||
|
|
4ac8a51bc2 | ||
|
|
82951cd04e | ||
|
|
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 | ||
|
|
a4be822636 | ||
|
|
7cd8dbf7d8 | ||
|
|
654a865007 | ||
|
|
409a0277fb | ||
|
|
7adab1f65f | ||
|
|
3b92c86582 | ||
|
|
de195a1c0f | ||
|
|
d7d2487f6d | ||
|
|
3e31945157 | ||
|
|
c11814c342 | ||
|
|
60e3244088 | ||
|
|
a1ac5f36f0 | ||
|
|
6f38d40613 | ||
|
|
6b7d22de3f | ||
|
|
2f763dceda | ||
|
|
96468c4083 | ||
|
|
858d52563f | ||
|
|
7505b3b271 | ||
|
|
758dc88b55 | ||
|
|
9be418dc65 | ||
|
|
2ac310456e | ||
|
|
ff264d6f7a | ||
|
|
8019c414e1 | ||
|
|
a3fbe3731e | ||
|
|
e3742dbc66 | ||
|
|
83bf717d6d | ||
|
|
7fcdaa1c46 | ||
|
|
a8154f9405 | ||
|
|
52a5afeba5 | ||
|
|
43cc40d7fe | ||
|
|
42a98f000e | ||
|
|
f003ef935b | ||
|
|
e2b2a184c8 | ||
|
|
094595c435 | ||
|
|
4118a86c0d | ||
|
|
928d6f3821 | ||
|
|
b2ade568c8 | ||
|
|
27820ce897 | ||
|
|
fea7e66996 | ||
|
|
474d1571ae | ||
|
|
2ef627b87e | ||
|
|
2dfb82b623 | ||
|
|
ccd7fdfd52 | ||
|
|
f92e59ed34 | ||
|
|
d29477349a | ||
|
|
259e480baa | ||
|
|
0055dd1142 | ||
|
|
61c44780f0 | ||
|
|
9710d3b15c | ||
|
|
04cefebd75 | ||
|
|
dbd927c002 | ||
|
|
e6b2116a2b | ||
|
|
855a02091f | ||
|
|
358434429e | ||
|
|
b00061d85e | ||
|
|
022f08c05e | ||
|
|
6ff5c8ce5f | ||
|
|
3be7fb9dd8 | ||
|
|
72dd970b6e | ||
|
|
f4d09ece43 | ||
|
|
239e6085ba | ||
|
|
b17eab3aeb | ||
|
|
752f7fcde0 | ||
|
|
f6b061f787 | ||
|
|
cc8a1290ee | ||
|
|
b7cf893169 | ||
|
|
1458cacdbb |
+219
-1
@@ -111,7 +111,7 @@ The event types page UI components are located in `apps/web/modules/event-types/
|
|||||||
|
|
||||||
Changes to shared UI patterns (like tab layouts and button alignments) need to be checked across multiple views to maintain consistency:
|
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`
|
- 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
|
- 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
|
## 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`
|
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
- **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
|
## When using Day.js
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next
|
||||||
|
**/.next
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
build
|
||||||
|
**/build
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
**/coverage
|
||||||
|
test-results
|
||||||
|
**/test-results
|
||||||
|
.turbo
|
||||||
|
**/.turbo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Examples and docs
|
||||||
|
docs
|
||||||
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
|
|||||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||||
GOOGLE_WEBHOOK_URL=
|
GOOGLE_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Token to verify incoming webhooks from Microsoft Calendar
|
||||||
|
MICROSOFT_WEBHOOK_TOKEN=
|
||||||
|
|
||||||
|
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||||
|
MICROSOFT_WEBHOOK_URL=
|
||||||
|
|
||||||
# Inbox to send user feedback
|
# Inbox to send user feedback
|
||||||
SEND_FEEDBACK_EMAIL=
|
SEND_FEEDBACK_EMAIL=
|
||||||
|
|
||||||
@@ -261,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
|
|||||||
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
|
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
|
||||||
CLOUDFLARE_TURNSTILE_SECRET=
|
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
|
# Set the following value to true if you wish to enable Team Impersonation
|
||||||
NEXT_PUBLIC_TEAM_IMPERSONATION=false
|
NEXT_PUBLIC_TEAM_IMPERSONATION=false
|
||||||
|
|
||||||
@@ -473,3 +482,10 @@ DATABASE_CHUNK_SIZE=
|
|||||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||||
# You can use: `openssl rand -base64 24` to generate one
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
SALESFORCE_GRAPHQL_DELAY_MS=500
|
||||||
|
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
|
||||||
|
SALESFORCE_GRAPHQL_MAX_RETRIES=3
|
||||||
|
|
||||||
|
# Force Node.js to use UTC as process timezone
|
||||||
|
TZ=UTC
|
||||||
|
|||||||
+5
-5
@@ -10,7 +10,6 @@
|
|||||||
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
||||||
/apps/web/middleware.ts @calcom/Foundation
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
/infra/**/* @calcom/Foundation
|
|
||||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||||
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
|
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
|
||||||
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
|
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
|
||||||
@@ -23,11 +22,12 @@
|
|||||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||||
/packages/embeds/**/* @calcom/Foundation
|
/packages/embeds/**/* @calcom/Foundation
|
||||||
|
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
|
||||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||||
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
|
/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation
|
||||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation
|
||||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation
|
||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/features/schedules/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/Foundation
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ runs:
|
|||||||
with:
|
with:
|
||||||
path: ${{ inputs.path }}
|
path: ${{ inputs.path }}
|
||||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
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'
|
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: Postgres Dump Backup
|
- name: Postgres Dump Backup
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
name: "Docker Build and Test"
|
||||||
|
description: "Reusable action to build and test Docker images for Cal.com"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
platform:
|
||||||
|
description: "Target platform (linux/amd64 or arm64)"
|
||||||
|
required: true
|
||||||
|
platform-suffix:
|
||||||
|
description: "Suffix to add to image tags (e.g., -arm)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
dockerhub-username:
|
||||||
|
description: "Docker Hub username"
|
||||||
|
required: true
|
||||||
|
dockerhub-token:
|
||||||
|
description: "Docker Hub token"
|
||||||
|
required: true
|
||||||
|
github-token:
|
||||||
|
description: "GitHub token for GHCR"
|
||||||
|
required: true
|
||||||
|
postgres-user:
|
||||||
|
description: "PostgreSQL user"
|
||||||
|
required: true
|
||||||
|
postgres-password:
|
||||||
|
description: "PostgreSQL password"
|
||||||
|
required: true
|
||||||
|
postgres-db:
|
||||||
|
description: "PostgreSQL database name"
|
||||||
|
required: true
|
||||||
|
database-host:
|
||||||
|
description: "Database host"
|
||||||
|
required: true
|
||||||
|
push-image:
|
||||||
|
description: "Whether to push the built image"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Log in to the Docker Hub registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ inputs.dockerhub-username }}
|
||||||
|
password: ${{ inputs.dockerhub-token }}
|
||||||
|
logout: true
|
||||||
|
|
||||||
|
- name: Log in to the Github Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ inputs.github-token }}
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
docker.io/calendso/calendso
|
||||||
|
docker.io/calcom/cal.com
|
||||||
|
ghcr.io/calcom/cal.com
|
||||||
|
flavor: |
|
||||||
|
latest=${{ !github.event.release.prerelease }}
|
||||||
|
suffix=${{ inputs.platform-suffix }}
|
||||||
|
|
||||||
|
- name: Copy env
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
grep -o '^[^#]*' .env.example > .env
|
||||||
|
cat .env >> $GITHUB_ENV
|
||||||
|
echo "DATABASE_HOST=localhost:5432" >> $GITHUB_ENV
|
||||||
|
eval $(sed -e '/^#/d' -e 's/^/export /' -e 's/$/;/' .env) ;
|
||||||
|
|
||||||
|
- name: Start database
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker compose up -d database
|
||||||
|
|
||||||
|
- name: Show database logs and container status
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "--- Container Status ---"
|
||||||
|
docker compose ps database
|
||||||
|
|
||||||
|
echo "--- Container Logs ---"
|
||||||
|
docker compose logs database
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: |
|
||||||
|
network=container:database
|
||||||
|
buildkitd-flags: |
|
||||||
|
--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
platforms: ${{ inputs.platform }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
|
||||||
|
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
||||||
|
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||||
|
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||||
|
|
||||||
|
- name: Test runtime
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
tags="${{ steps.meta.outputs.tags }}"
|
||||||
|
IFS=',' read -ra ADDR <<< "$tags"
|
||||||
|
tag=${ADDR[0]}
|
||||||
|
|
||||||
|
docker run --rm --network stack \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
|
||||||
|
-e DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
|
||||||
|
-e NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }} \
|
||||||
|
-e CALENDSO_ENCRYPTION_KEY=${{ env.CALENDSO_ENCRYPTION_KEY }} \
|
||||||
|
$tag &
|
||||||
|
|
||||||
|
server_pid=$!
|
||||||
|
|
||||||
|
echo "Waiting for the server to start..."
|
||||||
|
sleep 120
|
||||||
|
|
||||||
|
echo http://localhost:3000/auth/login
|
||||||
|
|
||||||
|
for i in {1..60}; do
|
||||||
|
echo "Checking server health ($i/60)..."
|
||||||
|
response=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/auth/login)
|
||||||
|
echo "HTTP Status Code: $response"
|
||||||
|
if [[ "$response" == "200" ]] || [[ "$response" == "307" ]]; then
|
||||||
|
echo "Server is healthy"
|
||||||
|
kill $server_pid
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Server health check failed"
|
||||||
|
kill $server_pid
|
||||||
|
exit 1
|
||||||
|
env:
|
||||||
|
NEXTAUTH_SECRET: "EI4qqDpcfdvf4A+0aQEEx8JjHxHSy4uWiZw/F32K+pA="
|
||||||
|
CALENDSO_ENCRYPTION_KEY: "0zfLtY99wjeLnsM7qsa8xsT+Q0oSgnOL"
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
id: docker_push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ inputs.push-image == 'true' && !github.event.release.prerelease }}
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: ${{ inputs.platform }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
|
||||||
|
NEXT_PUBLIC_LICENSE_CONSENT=agree
|
||||||
|
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||||
|
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
shell: bash
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
shell: bash
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker compose down
|
||||||
@@ -56,8 +56,8 @@ jobs:
|
|||||||
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
|
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
yarn workspace @calcom/api-v2 run generate-schemas
|
yarn workspace @calcom/api-v2 run generate-schemas &&
|
||||||
rm -rf apps/api/v2/node_modules
|
rm -rf apps/api/v2/node_modules &&
|
||||||
yarn install
|
yarn install &&
|
||||||
yarn workspace @calcom/api-v2 run build
|
yarn workspace @calcom/api-v2 run build
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -6,23 +6,31 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
permissions:
|
||||||
|
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||||
|
contents: write # to create release by changesets/action below
|
||||||
|
pull-requests: write # to create pull request by changesets/action below
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||||
permissions:
|
|
||||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
|
||||||
contents: write # to create release by changesets/action below
|
|
||||||
pull-requests: write # to create pull request by changesets/action below
|
|
||||||
name: Release
|
name: Release
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- name: Setup Node and registry for OIDC
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20.17.0"
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
always-auth: false
|
||||||
- name: Setup node, yarn and install dependencies
|
- name: Setup node, yarn and install dependencies
|
||||||
uses: ./.github/actions/yarn-install
|
uses: ./.github/actions/yarn-install
|
||||||
|
- name: Install npm at version supporting OIDC
|
||||||
|
run: npm install -g npm@11.5.1
|
||||||
- name: Create Release Pull Request or Publish to npm
|
- name: Create Release Pull Request or Publish to npm
|
||||||
id: changesets
|
id: changesets
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
@@ -33,8 +41,7 @@ jobs:
|
|||||||
title: "chore: version packages" # PR title
|
title: "chore: version packages" # PR title
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_CONFIG_PROVENANCE: "true"
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
||||||
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
||||||
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ jobs:
|
|||||||
days-before-close: -1
|
days-before-close: -1
|
||||||
days-before-issue-stale: 60
|
days-before-issue-stale: 60
|
||||||
days-before-issue-close: -1
|
days-before-issue-close: -1
|
||||||
days-before-pr-stale: 14
|
days-before-pr-stale: 7
|
||||||
days-before-pr-close: -1
|
days-before-pr-close: 14
|
||||||
stale-pr-message: "This PR is being marked as stale due to inactivity."
|
stale-pr-message: "This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active."
|
||||||
close-pr-message: "This PR is being closed due to inactivity. Please reopen if work is intended to be continued."
|
close-pr-message: "This PR has been closed due to inactivity. Please feel free to reopen it if you'd like to continue the work."
|
||||||
operations-per-run: 100
|
operations-per-run: 100
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||||
revision: docs/api-reference/v2/openapi.json
|
revision: docs/api-reference/v2/openapi.json
|
||||||
fail-on: WARN
|
fail-on: ERR
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ env:
|
|||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
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_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ env:
|
|||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
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_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
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_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 }}
|
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Web App
|
name: Build Web App
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
name: "Release Docker"
|
name: "Release Docker"
|
||||||
|
|
||||||
on: # yamllint disable-line rule:truthy
|
env:
|
||||||
release:
|
POSTGRES_USER: "unicorn_user"
|
||||||
types:
|
POSTGRES_PASSWORD: "magical_password"
|
||||||
- created
|
POSTGRES_DB: "calendso"
|
||||||
- published
|
DATABASE_HOST: "database:5432"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
# in case manual trigger is needed
|
# in case manual trigger is needed
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -12,11 +17,9 @@ on: # yamllint disable-line rule:truthy
|
|||||||
description: "v{Major}.{Minor}.{Patch}"
|
description: "v{Major}.{Minor}.{Patch}"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release-amd64:
|
||||||
name: "Remote Release"
|
name: "Release AMD64"
|
||||||
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
runs-on: "ubuntu-latest"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -24,25 +27,84 @@ jobs:
|
|||||||
- name: "Determine tag"
|
- name: "Determine tag"
|
||||||
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||||
|
|
||||||
- name: "Run remote release workflow"
|
- name: Build and test Docker image
|
||||||
uses: "actions/github-script@v6"
|
uses: ./.github/actions/docker-build-and-test
|
||||||
with:
|
with:
|
||||||
# Requires a personal access token with Actions Read and write permissions on calcom/docker.
|
platform: "linux/amd64"
|
||||||
github-token: "${{ secrets.DOCKER_REPO_ACCESS_TOKEN }}"
|
platform-suffix: ""
|
||||||
script: |
|
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
try {
|
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
const response = await github.rest.actions.createWorkflowDispatch({
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
owner: context.repo.owner,
|
postgres-user: ${{ env.POSTGRES_USER }}
|
||||||
repo: 'docker',
|
postgres-password: ${{ env.POSTGRES_PASSWORD }}
|
||||||
workflow_id: 'create-release.yaml',
|
postgres-db: ${{ env.POSTGRES_DB }}
|
||||||
ref: 'main',
|
database-host: ${{ env.DATABASE_HOST }}
|
||||||
inputs: {
|
push-image: "true"
|
||||||
"RELEASE_TAG": process.env.RELEASE_TAG
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(response);
|
- name: Notify Slack on Success
|
||||||
} catch (error) {
|
if: success()
|
||||||
console.error(error);
|
uses: slackapi/slack-github-action@v1.24.0
|
||||||
core.setFailed(error.message);
|
with:
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (AMD64) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
}
|
}
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||||
|
|
||||||
|
- name: Notify Slack on Failure
|
||||||
|
if: failure()
|
||||||
|
uses: slackapi/slack-github-action@v1.24.0
|
||||||
|
with:
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"text": ":red_circle: Workflow *${{ github.workflow }}* (AMD64) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||||
|
|
||||||
|
release-arm:
|
||||||
|
name: "Release ARM"
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Determine tag"
|
||||||
|
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||||
|
|
||||||
|
- name: Build and test Docker image
|
||||||
|
uses: ./.github/actions/docker-build-and-test
|
||||||
|
with:
|
||||||
|
platform: "arm64"
|
||||||
|
platform-suffix: "-arm"
|
||||||
|
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
postgres-user: ${{ env.POSTGRES_USER }}
|
||||||
|
postgres-password: ${{ env.POSTGRES_PASSWORD }}
|
||||||
|
postgres-db: ${{ env.POSTGRES_DB }}
|
||||||
|
database-host: ${{ env.DATABASE_HOST }}
|
||||||
|
push-image: "true"
|
||||||
|
|
||||||
|
- name: Notify Slack on Success
|
||||||
|
if: success()
|
||||||
|
uses: slackapi/slack-github-action@v1.24.0
|
||||||
|
with:
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (ARM) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||||
|
|
||||||
|
- name: Notify Slack on Failure
|
||||||
|
if: failure()
|
||||||
|
uses: slackapi/slack-github-action@v1.24.0
|
||||||
|
with:
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"text": ":red_circle: Workflow *${{ github.workflow }}* (ARM) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ tsconfig.tsbuildinfo
|
|||||||
|
|
||||||
# Prisma generated files
|
# Prisma generated files
|
||||||
packages/prisma/client/*
|
packages/prisma/client/*
|
||||||
|
!packages/prisma/client/index.ts
|
||||||
|
apps/api/v2/generated/prisma/*
|
||||||
|
packages/prisma/generated/prisma/*
|
||||||
packages/prisma/zod/**/*.ts
|
packages/prisma/zod/**/*.ts
|
||||||
packages/prisma/enums
|
packages/prisma/enums
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": "explicit"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM node:20 AS builder
|
||||||
|
|
||||||
|
WORKDIR /calcom
|
||||||
|
|
||||||
|
## If we want to read any ENV variable from .env file, we need to first accept and pass it as an argument to the Dockerfile
|
||||||
|
ARG NEXT_PUBLIC_LICENSE_CONSENT
|
||||||
|
ARG NEXT_PUBLIC_WEBSITE_TERMS_URL
|
||||||
|
ARG NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL
|
||||||
|
ARG CALCOM_TELEMETRY_DISABLED
|
||||||
|
ARG DATABASE_URL
|
||||||
|
ARG NEXTAUTH_SECRET=secret
|
||||||
|
ARG CALENDSO_ENCRYPTION_KEY=secret
|
||||||
|
ARG MAX_OLD_SPACE_SIZE=4096
|
||||||
|
ARG NEXT_PUBLIC_API_V2_URL
|
||||||
|
ARG CSP_POLICY
|
||||||
|
|
||||||
|
## We need these variables as required by Next.js build to create rewrites
|
||||||
|
ARG NEXT_PUBLIC_SINGLE_ORG_SLUG
|
||||||
|
ARG ORGANIZATIONS_ENABLED
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_WEBAPP_URL=http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER \
|
||||||
|
NEXT_PUBLIC_API_V2_URL=$NEXT_PUBLIC_API_V2_URL \
|
||||||
|
NEXT_PUBLIC_LICENSE_CONSENT=$NEXT_PUBLIC_LICENSE_CONSENT \
|
||||||
|
NEXT_PUBLIC_WEBSITE_TERMS_URL=$NEXT_PUBLIC_WEBSITE_TERMS_URL \
|
||||||
|
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=$NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL \
|
||||||
|
CALCOM_TELEMETRY_DISABLED=$CALCOM_TELEMETRY_DISABLED \
|
||||||
|
DATABASE_URL=$DATABASE_URL \
|
||||||
|
DATABASE_DIRECT_URL=$DATABASE_URL \
|
||||||
|
NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
|
||||||
|
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
|
||||||
|
NEXT_PUBLIC_SINGLE_ORG_SLUG=$NEXT_PUBLIC_SINGLE_ORG_SLUG \
|
||||||
|
ORGANIZATIONS_ENABLED=$ORGANIZATIONS_ENABLED \
|
||||||
|
NODE_OPTIONS=--max-old-space-size=${MAX_OLD_SPACE_SIZE} \
|
||||||
|
BUILD_STANDALONE=true \
|
||||||
|
CSP_POLICY=$CSP_POLICY
|
||||||
|
|
||||||
|
COPY package.json yarn.lock .yarnrc.yml playwright.config.ts turbo.json i18n.json ./
|
||||||
|
COPY .yarn ./.yarn
|
||||||
|
COPY apps/web ./apps/web
|
||||||
|
COPY apps/api/v2 ./apps/api/v2
|
||||||
|
COPY packages ./packages
|
||||||
|
COPY tests ./tests
|
||||||
|
|
||||||
|
RUN yarn config set httpTimeout 1200000
|
||||||
|
RUN npx turbo prune --scope=@calcom/web --scope=@calcom/trpc --docker
|
||||||
|
RUN yarn install
|
||||||
|
# Build and make embed servable from web/public/embed folder
|
||||||
|
RUN yarn workspace @calcom/trpc run build
|
||||||
|
RUN yarn --cwd packages/embeds/embed-core workspace @calcom/embed-core run build
|
||||||
|
RUN yarn --cwd apps/web workspace @calcom/web run build
|
||||||
|
RUN rm -rf node_modules/.cache .yarn/cache apps/web/.next/cache
|
||||||
|
|
||||||
|
FROM node:20 AS builder-two
|
||||||
|
|
||||||
|
WORKDIR /calcom
|
||||||
|
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json .yarnrc.yml turbo.json i18n.json ./
|
||||||
|
COPY .yarn ./.yarn
|
||||||
|
COPY --from=builder /calcom/yarn.lock ./yarn.lock
|
||||||
|
COPY --from=builder /calcom/node_modules ./node_modules
|
||||||
|
COPY --from=builder /calcom/packages ./packages
|
||||||
|
COPY --from=builder /calcom/apps/web ./apps/web
|
||||||
|
COPY --from=builder /calcom/packages/prisma/schema.prisma ./prisma/schema.prisma
|
||||||
|
COPY scripts scripts
|
||||||
|
RUN chmod +x scripts/*
|
||||||
|
|
||||||
|
# Save value used during this build stage. If NEXT_PUBLIC_WEBAPP_URL and BUILT_NEXT_PUBLIC_WEBAPP_URL differ at
|
||||||
|
# run-time, then start.sh will find/replace static values again.
|
||||||
|
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||||
|
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
|
||||||
|
|
||||||
|
RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_WEBAPP_URL}
|
||||||
|
|
||||||
|
FROM node:20 AS runner
|
||||||
|
|
||||||
|
WORKDIR /calcom
|
||||||
|
COPY --from=builder-two /calcom ./
|
||||||
|
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||||
|
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
|
||||||
|
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --retries=5 \
|
||||||
|
CMD wget --spider http://localhost:3000 || exit 1
|
||||||
|
|
||||||
|
CMD ["/calcom/scripts/start.sh"]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
<h3 align="center">Cal.com</h3>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
The open-source Calendly successor.
|
The open-source Calendly successor.
|
||||||
@@ -175,6 +175,7 @@ yarn dx
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Development tip
|
#### Development tip
|
||||||
|
|
||||||
1. Add `export NODE_OPTIONS=“--max-old-space-size=16384”` to your shell script to increase the memory limit for the node process. Alternatively, you can run this in your terminal before running the app. Replace 16384 with the amount of RAM you want to allocate to the node process.
|
1. Add `export NODE_OPTIONS=“--max-old-space-size=16384”` to your shell script to increase the memory limit for the node process. Alternatively, you can run this in your terminal before running the app. Replace 16384 with the amount of RAM you want to allocate to the node process.
|
||||||
|
|
||||||
2. Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
|
2. Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
|
||||||
@@ -226,7 +227,7 @@ for Logger level to be set at info, for example.
|
|||||||
|
|
||||||
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
|
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
|
||||||
|
|
||||||
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
|
||||||

|

|
||||||
|
|
||||||
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
|
||||||
@@ -234,9 +235,10 @@ for Logger level to be set at info, for example.
|
|||||||
|
|
||||||
</details>
|
</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 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)
|
- [Setup postgres DB with render](https://render.com/docs/databases)
|
||||||
|
|
||||||
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
|
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
|
||||||
@@ -374,15 +376,268 @@ Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1
|
|||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
The Docker configuration for Cal.com is an effort powered by people within the community.
|
**Official support**: Our team will begin to officially support the Dockerfile and docker-compose resources in this
|
||||||
|
repository.
|
||||||
|
|
||||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calcom/docker/discussions/32).
|
**Important**: Cal.com will **not** be supporting installations that use these Docker resources. While we provide and maintain the Docker configurations, support for Docker-based installations is the responsibility of the user.
|
||||||
|
|
||||||
The Docker configuration can be found [in our docker repository](https://github.com/calcom/docker).
|
This image can be found on DockerHub at [https://hub.docker.com/r/calcom/cal.com](https://hub.docker.com/r/calcom/cal.com).
|
||||||
|
|
||||||
Issues with Docker? Find your answer or open a new discussion [here](https://github.com/calcom/docker/discussions) to ask the community.
|
**Note for ARM Users**: Use the {version}-arm suffix for pulling images. Example: `docker pull calcom/cal.com:v5.6.19-arm`.
|
||||||
|
|
||||||
Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
#### Requirements
|
||||||
|
|
||||||
|
Make sure you have `docker` & `docker compose` installed on the server / system. Both are installed by most docker utilities, including Docker Desktop and Rancher Desktop.
|
||||||
|
|
||||||
|
Note: `docker compose` without the hyphen is now the primary method of using docker-compose, per the Docker documentation.
|
||||||
|
|
||||||
|
#### (Most users) Running Cal.com with Docker Compose
|
||||||
|
|
||||||
|
If you are evaluating Cal.com or running with minimal to no modifications, this option is for you.
|
||||||
|
|
||||||
|
1. Clone calcom/cal.com
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://github.com/calcom/cal.com.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Change into the directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cal.com
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Prepare your configuration: Rename `.env.example` to `.env` and then update `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Most configurations can be left as-is, but for configuration options see [Important Run-time variables](#important-run-time-variables) below.
|
||||||
|
|
||||||
|
**Push Notifications (VAPID Keys)**
|
||||||
|
If you see an error like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: No key set vapidDetails.publicKey
|
||||||
|
```
|
||||||
|
|
||||||
|
This means your environment variables for Web Push are missing.
|
||||||
|
You must generate and set `NEXT_PUBLIC_VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`.
|
||||||
|
|
||||||
|
Generate them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx web-push generate-vapid-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
|
||||||
|
VAPID_PRIVATE_KEY=your_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** commit real keys to `.env.example` — only placeholders.
|
||||||
|
|
||||||
|
Update the appropriate values in your .env file, then proceed.
|
||||||
|
|
||||||
|
4. (optional) Pre-Pull the images by running the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
This will use the default image locations as specified by `image:` in the docker-compose.yaml file.
|
||||||
|
|
||||||
|
Note: To aid with support, by default Scarf.sh is used as registry proxy for download metrics.
|
||||||
|
|
||||||
|
5. Start Cal.com via docker compose
|
||||||
|
|
||||||
|
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d calcom studio
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d calcom
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
|
||||||
|
|
||||||
|
6. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
|
||||||
|
|
||||||
|
#### Updating Cal.com
|
||||||
|
|
||||||
|
1. Stop the Cal.com stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Pull the latest changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update env vars as necessary.
|
||||||
|
4. Re-start the Cal.com stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### (Advanced users) Build and Run Cal.com
|
||||||
|
|
||||||
|
1. Clone calcom/docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/calcom/cal.com.git calcom-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Change into the directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd calcom-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update the calcom submodule. This project depends on the Cal.com source code, which is included here as a Git submodule. To make sure you get everything you need, update the submodule with the command below.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --remote --init
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: DO NOT use recursive submodule update, otherwise you will receive a git authentication error.
|
||||||
|
|
||||||
|
4. Rename `.env.example` to `.env` and then update `.env`
|
||||||
|
|
||||||
|
For configuration options see [Build-time variables](#build-time-variables) below. Update the appropriate values in your .env file, then proceed.
|
||||||
|
|
||||||
|
5. Build the Cal.com docker image:
|
||||||
|
|
||||||
|
Note: Due to application configuration requirements, an available database is currently required during the build process.
|
||||||
|
|
||||||
|
a) If hosting elsewhere, configure the `DATABASE_URL` in the .env file, and skip the next step
|
||||||
|
|
||||||
|
b) If a local or temporary database is required, start a local database via docker compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d database
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Build Cal.com via docker compose (DOCKER_BUILDKIT=0 must be provided to allow a network bridge to be used at build time. This requirement will be removed in the future)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=0 docker compose build calcom
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Start Cal.com via docker compose
|
||||||
|
|
||||||
|
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d calcom studio
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d calcom
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
|
||||||
|
|
||||||
|
8. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
##### Important Run-time variables
|
||||||
|
|
||||||
|
These variables must also be provided at runtime
|
||||||
|
|
||||||
|
| Variable | Description | Required | Default |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------- |
|
||||||
|
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
|
||||||
|
| CALCOM_LICENSE_KEY | Enterprise License Key | optional | |
|
||||||
|
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. NOTE: if this value differs from the value used at build-time, there will be a slight delay during container start (to update the statically built files). | optional | `http://localhost:3000` |
|
||||||
|
| NEXTAUTH_URL | Location of the auth server. By default, this is the Cal.com docker instance itself. | optional | `{NEXT_PUBLIC_WEBAPP_URL}/api/auth` |
|
||||||
|
| NEXTAUTH_SECRET | must match build variable | required | `secret` |
|
||||||
|
| CALENDSO_ENCRYPTION_KEY | must match build variable | required | `secret` |
|
||||||
|
|
||||||
|
##### Build-time variables
|
||||||
|
|
||||||
|
If building the image yourself, these variables must be provided at the time of the docker build, and can be provided by updating the .env file. Currently, if you require changes to these variables, you must follow the instructions to build and publish your own image.
|
||||||
|
|
||||||
|
Updating these variables is not required for evaluation, but is required for running in production. Instructions for generating variables can be found in the [Cal.com instructions](https://github.com/calcom/cal.com)
|
||||||
|
|
||||||
|
| Variable | Description | Required | Default |
|
||||||
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------- |
|
||||||
|
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
|
||||||
|
| MAX_OLD_SPACE_SIZE | Needed for Nodejs/NPM build options | required | 4096 |
|
||||||
|
| NEXT_PUBLIC_LICENSE_CONSENT | license consent - true/false | required | |
|
||||||
|
| NEXTAUTH_SECRET | Cookie encryption key | required | `secret` |
|
||||||
|
| CALENDSO_ENCRYPTION_KEY | Authentication encryption key | required | `secret` |
|
||||||
|
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files | optional | `http://localhost:3000` |
|
||||||
|
| NEXT_PUBLIC_WEBSITE_TERMS_URL | custom URL for terms and conditions website | optional | `https://cal.com/terms` |
|
||||||
|
| NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL | custom URL for privacy policy website | optional | `https://cal.com/privacy` |
|
||||||
|
| NEXT_PUBLIC_API_V2_URL | URL for the v2 API, only required for custom integrations or custom booking experiences using [Cal.com Platform](https://cal.com/platform) | optional | |
|
||||||
|
| CALCOM_TELEMETRY_DISABLED | Allow Cal.com to collect anonymous usage data (set to `1` to disable) | optional | |
|
||||||
|
| NEXT_PUBLIC_SINGLE_ORG_SLUG | Required if ORGANIZATIONS_ENABLED is true | optional | |
|
||||||
|
| ORGANIZATIONS_ENABLED | Used for Enterprise or Organizations plan | optional | |
|
||||||
|
|
||||||
|
#### Troubleshooting
|
||||||
|
|
||||||
|
##### SSL edge termination
|
||||||
|
|
||||||
|
If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
|
||||||
|
|
||||||
|
##### Failed to commit changes: Invalid 'prisma.user.create()'
|
||||||
|
|
||||||
|
Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
|
||||||
|
|
||||||
|
##### CLIENT_FETCH_ERROR
|
||||||
|
|
||||||
|
If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefor needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
|
||||||
|
docker-calcom-1 | @calcom/web:start: https://next-auth.js.org/errors#client_fetch_error request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost {
|
||||||
|
docker-calcom-1 | @calcom/web:start: error: {
|
||||||
|
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost',
|
||||||
|
docker-calcom-1 | @calcom/web:start: stack: 'FetchError: request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.<anonymous> (/calcom/node_modules/next/dist/compiled/node-fetch/index.js:1:65756)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:events:513:28)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:domain:489:12)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at Socket.socketErrorListener (node:_http_client:494:9)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:events:513:28)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:domain:489:12)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at emitErrorNT (node:internal/streams/destroy:157:8)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at emitErrorCloseNT (node:internal/streams/destroy:122:3)\n' +
|
||||||
|
docker-calcom-1 | @calcom/web:start: ' at processTicksAndRejections (node:internal/process/task_queues:83:21)',
|
||||||
|
docker-calcom-1 | @calcom/web:start: name: 'FetchError'
|
||||||
|
docker-calcom-1 | @calcom/web:start: },
|
||||||
|
docker-calcom-1 | @calcom/web:start: url: 'http://testing.localhost:3000/api/auth/session',
|
||||||
|
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost'
|
||||||
|
docker-calcom-1 | @calcom/web:start: }
|
||||||
|
```
|
||||||
|
|
||||||
|
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=81cda9f7-a102-453b-ac01-51c35650bd70" />
|
||||||
|
|
||||||
### Railway
|
### Railway
|
||||||
|
|
||||||
@@ -390,6 +645,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.
|
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
|
### 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.
|
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.
|
||||||
@@ -418,7 +679,7 @@ See the [roadmap project](https://cal.com/roadmap) for a list of proposed featur
|
|||||||
|
|
||||||
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||||
|
|
||||||
| | AGPLv3 | EE |
|
| | AGPLv3 | EE |
|
||||||
@@ -490,8 +751,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
|
|||||||
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||||
|
|
||||||
## Single Org Mode
|
## Single Org Mode
|
||||||
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
|
||||||
|
|
||||||
|
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
@@ -648,6 +909,7 @@ following
|
|||||||
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||||
|
|
||||||
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||||
|
|
||||||
1. Follow semantic versioning when using changesets.
|
1. Follow semantic versioning when using changesets.
|
||||||
2. Mark breaking changes using `❗️Breaking change`
|
2. Mark breaking changes using `❗️Breaking change`
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createMocks } from "node-mocks-http";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
|||||||
checkRateLimitAndThrowError: vi.fn(),
|
checkRateLimitAndThrowError: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/autoLock", () => ({
|
vi.mock("@calcom/features/ee/api-keys/lib/autoLock", () => ({
|
||||||
handleAutoLock: vi.fn(),
|
handleAutoLock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextMiddleware } from "next-api-middleware";
|
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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
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 { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||||
import prisma from "@calcom/prisma";
|
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
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";
|
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", () => ({
|
vi.mock("@calcom/lib/crypto", () => ({
|
||||||
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
||||||
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
||||||
@@ -32,17 +55,29 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mockDeploymentRepository: IDeploymentRepository = {
|
const mockDeploymentRepository: IDeploymentRepository = {
|
||||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"),
|
||||||
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Verify API key", () => {
|
describe("Verify API key - Unit Tests", () => {
|
||||||
let service: ILicenseKeyService;
|
let service: ILicenseKeyService;
|
||||||
|
let mockApiKeyService: ApiKeyService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
service = await LicenseKeyService.create(mockDeploymentRepository);
|
service = await LicenseKeyService.create(mockDeploymentRepository);
|
||||||
|
|
||||||
vi.spyOn(service, "checkLicense");
|
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 () => {
|
it("should throw an error if the api key is not valid", async () => {
|
||||||
@@ -98,22 +133,25 @@ describe("Verify API key", () => {
|
|||||||
query: {
|
query: {
|
||||||
apiKey: "cal_test_key",
|
apiKey: "cal_test_key",
|
||||||
},
|
},
|
||||||
prisma,
|
|
||||||
});
|
});
|
||||||
const hashedKey = hashAPIKey("test_key");
|
|
||||||
await prismock.apiKey.create({
|
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||||
data: {
|
valid: true,
|
||||||
hashedKey,
|
userId: 1,
|
||||||
user: {
|
user: {
|
||||||
create: {
|
role: UserPermissionRole.ADMIN,
|
||||||
email: "admin@example.com",
|
locked: false,
|
||||||
role: UserPermissionRole.ADMIN,
|
email: "admin@example.com",
|
||||||
locked: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||||
|
isAdmin: true,
|
||||||
|
scope: ScopeOfAdmin.SystemWide,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
|
||||||
|
|
||||||
const middleware = {
|
const middleware = {
|
||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
@@ -139,40 +177,25 @@ describe("Verify API key", () => {
|
|||||||
query: {
|
query: {
|
||||||
apiKey: "cal_test_key",
|
apiKey: "cal_test_key",
|
||||||
},
|
},
|
||||||
prisma,
|
|
||||||
});
|
});
|
||||||
const hashedKey = hashAPIKey("test_key");
|
|
||||||
await prismock.apiKey.create({
|
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||||
data: {
|
valid: true,
|
||||||
hashedKey,
|
userId: 2,
|
||||||
user: {
|
user: {
|
||||||
create: {
|
role: UserPermissionRole.USER,
|
||||||
email: "org-admin@acme.com",
|
locked: false,
|
||||||
role: UserPermissionRole.USER,
|
email: "org-admin@acme.com",
|
||||||
locked: false,
|
|
||||||
teams: {
|
|
||||||
create: {
|
|
||||||
accepted: true,
|
|
||||||
role: MembershipRole.OWNER,
|
|
||||||
team: {
|
|
||||||
create: {
|
|
||||||
name: "ACME",
|
|
||||||
isOrganization: true,
|
|
||||||
organizationSettings: {
|
|
||||||
create: {
|
|
||||||
isAdminAPIEnabled: true,
|
|
||||||
orgAutoAcceptEmail: "acme.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||||
|
isAdmin: true,
|
||||||
|
scope: ScopeOfAdmin.OrgOwnerOrAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
|
||||||
|
|
||||||
const middleware = {
|
const middleware = {
|
||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
@@ -198,22 +221,25 @@ describe("Verify API key", () => {
|
|||||||
query: {
|
query: {
|
||||||
apiKey: "cal_test_key",
|
apiKey: "cal_test_key",
|
||||||
},
|
},
|
||||||
prisma,
|
|
||||||
});
|
});
|
||||||
const hashedKey = hashAPIKey("test_key");
|
|
||||||
await prismock.apiKey.create({
|
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||||
data: {
|
valid: true,
|
||||||
hashedKey,
|
userId: 3,
|
||||||
user: {
|
user: {
|
||||||
create: {
|
role: UserPermissionRole.USER,
|
||||||
email: "locked@example.com",
|
locked: true,
|
||||||
role: UserPermissionRole.USER,
|
email: "locked@example.com",
|
||||||
locked: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||||
|
isAdmin: false,
|
||||||
|
scope: ScopeOfAdmin.SystemWide,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(isLockedOrBlocked).mockResolvedValue(true);
|
||||||
|
|
||||||
const middleware = {
|
const middleware = {
|
||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,21 +3,15 @@ import type { NextMiddleware } from "next-api-middleware";
|
|||||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
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 { isAdminGuard } from "../utils/isAdmin";
|
||||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
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.
|
// This verifies the apiKey and sets the user if it is valid.
|
||||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
const deploymentRepo = new DeploymentRepository(prisma);
|
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 strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||||
const hashedKey = hashAPIKey(strippedApiKey);
|
const hashedKey = hashAPIKey(strippedApiKey);
|
||||||
const apiKey = await prisma.apiKey.findUnique({
|
|
||||||
where: { hashedKey },
|
// Use service layer for API key verification
|
||||||
include: {
|
const apiKeyRepo = new PrismaApiKeyRepository(prisma);
|
||||||
user: {
|
const apiKeyService = new ApiKeyService({ apiKeyRepo });
|
||||||
select: { role: true, locked: true, email: true },
|
const result = await apiKeyService.verifyKeyByHashedKey(hashedKey);
|
||||||
},
|
|
||||||
},
|
if (!result.valid) {
|
||||||
});
|
return res.status(401).json({ error: result.error });
|
||||||
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." });
|
|
||||||
}
|
}
|
||||||
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
|
// save the user id in the request for later use
|
||||||
req.userId = apiKey.userId;
|
req.userId = result.userId!;
|
||||||
req.user = apiKey.user;
|
req.user = result.user!;
|
||||||
|
|
||||||
const { isAdmin, scope } = await isAdminGuard(req);
|
const { isAdmin, scope } = await isAdminGuard(req);
|
||||||
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
|
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { label } from "next-api-middleware";
|
|||||||
import { addRequestId } from "./addRequestid";
|
import { addRequestId } from "./addRequestid";
|
||||||
import { captureErrors } from "./captureErrors";
|
import { captureErrors } from "./captureErrors";
|
||||||
import { captureUserId } from "./captureUserId";
|
import { captureUserId } from "./captureUserId";
|
||||||
import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode";
|
|
||||||
import { extendRequest } from "./extendRequest";
|
import { extendRequest } from "./extendRequest";
|
||||||
import {
|
import {
|
||||||
HTTP_DELETE,
|
HTTP_DELETE,
|
||||||
@@ -26,7 +25,6 @@ const middleware = {
|
|||||||
HTTP_POST,
|
HTTP_POST,
|
||||||
HTTP_DELETE,
|
HTTP_DELETE,
|
||||||
addRequestId,
|
addRequestId,
|
||||||
checkIsInMaintenanceMode,
|
|
||||||
verifyApiKey,
|
verifyApiKey,
|
||||||
rateLimitApiKey,
|
rateLimitApiKey,
|
||||||
extendRequest,
|
extendRequest,
|
||||||
@@ -38,16 +36,14 @@ const middleware = {
|
|||||||
|
|
||||||
type Middleware = keyof typeof middleware;
|
type Middleware = keyof typeof middleware;
|
||||||
|
|
||||||
const middlewareOrder = [
|
const middlewareOrder: Middleware[] = [
|
||||||
// The order here, determines the order of execution
|
|
||||||
"checkIsInMaintenanceMode",
|
|
||||||
"extendRequest",
|
"extendRequest",
|
||||||
"captureErrors",
|
"captureErrors",
|
||||||
"verifyApiKey",
|
"verifyApiKey",
|
||||||
"rateLimitApiKey",
|
"rateLimitApiKey",
|
||||||
"addRequestId",
|
"addRequestId",
|
||||||
"captureUserId",
|
"captureUserId",
|
||||||
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
];
|
||||||
|
|
||||||
const withMiddleware = label(middleware, middlewareOrder);
|
const withMiddleware = label(middleware, middlewareOrder);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { NextApiRequest } from "next";
|
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";
|
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||||
|
|
||||||
export async function isLockedOrBlocked(req: NextApiRequest) {
|
export async function isLockedOrBlocked(req: NextApiRequest) {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
if (!user?.email) return false;
|
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: {
|
where: {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
team: {
|
team: {
|
||||||
@@ -56,16 +56,19 @@ export const getAccessibleUsers = async ({
|
|||||||
memberUserIds,
|
memberUserIds,
|
||||||
adminUserId,
|
adminUserId,
|
||||||
}: AccessibleUsersType): Promise<number[]> => {
|
}: 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({
|
const memberships = await prisma.membership.findMany({
|
||||||
where: {
|
where: {
|
||||||
team: {
|
team: {
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
},
|
},
|
||||||
accepted: true,
|
accepted: true,
|
||||||
OR: [
|
OR: orConditions,
|
||||||
{ userId: { in: memberUserIds } },
|
|
||||||
{ userId: adminUserId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
userId: true,
|
userId: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
|
||||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
|
||||||
import { iso8601 } from "@calcom/prisma/zod-utils";
|
import { iso8601 } from "@calcom/prisma/zod-utils";
|
||||||
import { UserSchema } from "@calcom/prisma/zod/modelSchema/UserSchema";
|
import { UserSchema } from "@calcom/prisma/zod/modelSchema/UserSchema";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ async function authMiddleware(req: NextApiRequest) {
|
|||||||
if (isSystemWideAdmin) return;
|
if (isSystemWideAdmin) return;
|
||||||
// Find all references where the user has bookings
|
// Find all references where the user has bookings
|
||||||
const bookingReference = await prisma.bookingReference.findFirst({
|
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" });
|
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) {
|
export async function deleteHandler(req: NextApiRequest) {
|
||||||
const { query } = req;
|
const { query } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
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` };
|
return { message: `BookingReference with id: ${id} deleted` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
|||||||
export async function getHandler(req: NextApiRequest) {
|
export async function getHandler(req: NextApiRequest) {
|
||||||
const { query } = req;
|
const { query } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
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) };
|
return { booking_reference: schemaBookingReferenceReadPublic.parse(booking_reference) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ import { schemaBookingReferenceReadPublic } from "~/lib/validations/booking-refe
|
|||||||
* 404:
|
* 404:
|
||||||
* description: No booking references were found
|
* description: No booking references were found
|
||||||
*/
|
*/
|
||||||
async function getHandler(req: NextApiRequest) {
|
export async function handler(req: NextApiRequest) {
|
||||||
const { userId, isSystemWideAdmin } = req;
|
const { userId, isSystemWideAdmin } = req;
|
||||||
const args: Prisma.BookingReferenceFindManyArgs = isSystemWideAdmin
|
const args: Prisma.BookingReferenceFindManyArgs = isSystemWideAdmin
|
||||||
? {}
|
? { where: { deleted: null } }
|
||||||
: { where: { booking: { userId } } };
|
: { where: { booking: { userId }, deleted: null } };
|
||||||
const data = await prisma.bookingReference.findMany(args);
|
const data = await prisma.bookingReference.findMany(args);
|
||||||
return { booking_references: data.map((br) => schemaBookingReferenceReadPublic.parse(br)) };
|
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 {
|
import {
|
||||||
getRecordingsOfCalVideoByRoomName,
|
getRecordingsOfCalVideoByRoomName,
|
||||||
getDownloadLinkOfCalVideoByRecordingId,
|
getDownloadLinkOfCalVideoByRecordingId,
|
||||||
} from "@calcom/app-store/videoClient";
|
} from "@calcom/features/conferencing/lib/videoClient";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
|
|||||||
import {
|
import {
|
||||||
getTranscriptsAccessLinkFromRecordingId,
|
getTranscriptsAccessLinkFromRecordingId,
|
||||||
checkIfRoomNameMatchesInRecording,
|
checkIfRoomNameMatchesInRecording,
|
||||||
} from "@calcom/app-store/videoClient";
|
} from "@calcom/features/conferencing/lib/videoClient";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextApiRequest } from "next";
|
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 { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container";
|
||||||
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
||||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
@@ -239,15 +239,17 @@ async function handler(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await handleNewBooking(
|
const regularBookingService = getRegularBookingService();
|
||||||
{
|
|
||||||
bookingData: req.body,
|
return await regularBookingService.createBookingForApiV1({
|
||||||
|
bookingData: req.body,
|
||||||
|
bookingMeta: {
|
||||||
userId,
|
userId,
|
||||||
hostname: req.headers.host || "",
|
hostname: req.headers.host || "",
|
||||||
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||||
},
|
},
|
||||||
getBookingDataSchemaForApi
|
bookingDataSchemaGetter: getBookingDataSchemaForApi,
|
||||||
);
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const knownError = error as Error;
|
const knownError = error as Error;
|
||||||
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
|
import type { UserWithCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||||
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/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 { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
|
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import utc from "dayjs/plugin/utc";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
@@ -48,7 +48,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
: availableSlots.slots;
|
: availableSlots.slots;
|
||||||
|
|
||||||
return { slots: slotsInProvidedTimeZone };
|
return { slots: slotsInProvidedTimeZone };
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof TRPCError) {
|
if (cause instanceof TRPCError) {
|
||||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
|
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 { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
@@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
const { teamId } = schemaQueryTeamId.parse(req.query);
|
const { teamId } = schemaQueryTeamId.parse(req.query);
|
||||||
|
|
||||||
/** Only OWNERS and ADMINS can edit teams */
|
/** Only OWNERS and ADMINS can edit teams */
|
||||||
const _team = await prisma.team.findFirst({
|
const team = await prisma.team.findFirst({
|
||||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||||
include: { members: true },
|
include: { members: true },
|
||||||
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||||
});
|
});
|
||||||
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
|
if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
|
||||||
|
|
||||||
const slugAlreadyExists = await prisma.team.findFirst({
|
if (data.slug) {
|
||||||
where: {
|
const teamRepository = new TeamRepository(prisma);
|
||||||
slug: {
|
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
|
||||||
mode: "insensitive",
|
slug: data.slug,
|
||||||
equals: data.slug,
|
teamId: team.id,
|
||||||
},
|
parentId: team.parentId,
|
||||||
},
|
});
|
||||||
});
|
if (!isSlugAvailable) {
|
||||||
|
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||||
if (slugAlreadyExists && data.slug !== _team.slug)
|
}
|
||||||
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
}
|
||||||
|
|
||||||
// Check if parentId is related to this user
|
// Check if parentId is related to this user
|
||||||
if (data.parentId && data.parentId === teamId) {
|
if (data.parentId && data.parentId === teamId) {
|
||||||
@@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let paymentUrl;
|
let paymentUrl;
|
||||||
if (_team.slug === null && data.slug) {
|
if (team.slug === null && data.slug) {
|
||||||
data.metadata = {
|
data.metadata = {
|
||||||
...(_team.metadata as Prisma.JsonObject),
|
...(team.metadata as Prisma.JsonObject),
|
||||||
requestedSlug: data.slug,
|
requestedSlug: data.slug,
|
||||||
};
|
};
|
||||||
delete data.slug;
|
delete data.slug;
|
||||||
if (IS_TEAM_BILLING_ENABLED) {
|
if (IS_TEAM_BILLING_ENABLED) {
|
||||||
const checkoutSession = await purchaseTeamOrOrgSubscription({
|
const checkoutSession = await purchaseTeamOrOrgSubscription({
|
||||||
teamId: _team.id,
|
teamId: team.id,
|
||||||
seatsUsed: _team.members.length,
|
seatsUsed: team.members.length,
|
||||||
userId,
|
userId,
|
||||||
pricePerSeat: null,
|
pricePerSeat: null,
|
||||||
});
|
});
|
||||||
@@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
|
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
|
||||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||||
};
|
};
|
||||||
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||||
const result = {
|
const result = {
|
||||||
team: schemaTeamReadPublic.parse(team),
|
team: schemaTeamReadPublic.parse(updatedTeam),
|
||||||
paymentUrl,
|
paymentUrl,
|
||||||
};
|
};
|
||||||
if (!paymentUrl) {
|
if (!paymentUrl) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextApiRequest } from "next";
|
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 { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { UserCreationService } from "@calcom/features/users/services/userCreationService";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { UserCreationService } from "@calcom/lib/server/service/userCreationService";
|
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -11,6 +11,30 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
|||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
describe("PATCH /api/bookings", () => {
|
describe("PATCH /api/bookings", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const acmeOrg = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: "acme",
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acmeOrg) {
|
||||||
|
await prisma.organizationSettings.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
orgAutoAcceptEmail: "acme.com",
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
it("Returns 403 when user has no permission to the booking", async () => {
|
it("Returns 403 when user has no permission to the booking", async () => {
|
||||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
import { buildBooking } from "@calcom/lib/test/builder";
|
|
||||||
import {
|
import {
|
||||||
getRecordingsOfCalVideoByRoomName,
|
getRecordingsOfCalVideoByRoomName,
|
||||||
getDownloadLinkOfCalVideoByRecordingId,
|
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";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
|
|||||||
const adminUserId = 1;
|
const adminUserId = 1;
|
||||||
const memberUserId = 10;
|
const memberUserId = 10;
|
||||||
|
|
||||||
vi.mock("@calcom/app-store/videoClient", () => {
|
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
|
||||||
return {
|
return {
|
||||||
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
||||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { describe, expect, test, vi, afterEach } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getTranscriptsAccessLinkFromRecordingId,
|
getTranscriptsAccessLinkFromRecordingId,
|
||||||
checkIfRoomNameMatchesInRecording,
|
checkIfRoomNameMatchesInRecording,
|
||||||
} from "@calcom/app-store/videoClient";
|
} from "@calcom/features/conferencing/lib/videoClient";
|
||||||
import { buildBooking } from "@calcom/lib/test/builder";
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
|
|||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
vi.mock("@calcom/app-store/videoClient", () => {
|
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
|
||||||
return {
|
return {
|
||||||
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
||||||
checkIfRoomNameMatchesInRecording: vi.fn(),
|
checkIfRoomNameMatchesInRecording: vi.fn(),
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
|
||||||
import { buildBooking } from "@calcom/lib/test/builder";
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
|
|||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
vi.mock("@calcom/app-store/videoClient", () => {
|
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
|
||||||
return {
|
return {
|
||||||
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, beforeAll } from "vitest";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { handler } from "../../../pages/api/bookings/_get";
|
import { handler } from "../../../pages/api/bookings/_get";
|
||||||
|
|
||||||
@@ -17,6 +17,30 @@ const DefaultPagination = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("GET /api/bookings", async () => {
|
describe("GET /api/bookings", async () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const acmeOrg = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: "acme",
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acmeOrg) {
|
||||||
|
await prisma.organizationSettings.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
orgAutoAcceptEmail: "acme.com",
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||||
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||||
|
|
||||||
@@ -193,6 +217,22 @@ describe("GET /api/bookings", async () => {
|
|||||||
describe("Expand feature to add relational data in return payload", () => {
|
describe("Expand feature to add relational data in return payload", () => {
|
||||||
it("Returns only team data when expand=team is set", async () => {
|
it("Returns only team data when expand=team is set", async () => {
|
||||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|
||||||
|
// Find a team booking and a non-team booking from seed data
|
||||||
|
const team1 = await prisma.team.findFirst({ where: { slug: "team1" } });
|
||||||
|
const teamEventType = await prisma.eventType.findFirst({
|
||||||
|
where: { teamId: team1?.id },
|
||||||
|
include: { bookings: true },
|
||||||
|
});
|
||||||
|
const teamBooking = teamEventType?.bookings[0];
|
||||||
|
|
||||||
|
const nonTeamBooking = await prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
eventType: { teamId: null },
|
||||||
|
userId: proUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
query: {
|
query: {
|
||||||
@@ -205,14 +245,23 @@ describe("GET /api/bookings", async () => {
|
|||||||
req.isOrganizationOwnerOrAdmin = true;
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
|
||||||
const responseData = await handler(req);
|
const responseData = await handler(req);
|
||||||
console.log("bookings=>", responseData.bookings);
|
|
||||||
responseData.bookings.forEach((booking) => {
|
// Verify team booking has team data
|
||||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
if (teamBooking) {
|
||||||
if (booking.id === 19) {
|
const returnedTeamBooking = responseData.bookings.find((b) => b.id === teamBooking.id);
|
||||||
// The team field can be either null or undefined due to nullish() in the schema
|
expect(returnedTeamBooking?.eventType?.team?.slug).toBe("team1");
|
||||||
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
}
|
||||||
|
|
||||||
|
// Verify non-team booking has null/undefined team
|
||||||
|
if (nonTeamBooking) {
|
||||||
|
const returnedNonTeamBooking = responseData.bookings.find((b) => b.id === nonTeamBooking.id);
|
||||||
|
if (returnedNonTeamBooking) {
|
||||||
|
expect(
|
||||||
|
returnedNonTeamBooking.eventType?.team === null ||
|
||||||
|
returnedNonTeamBooking.eventType?.team === undefined
|
||||||
|
).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,11 +367,6 @@ describe("GET /api/bookings", async () => {
|
|||||||
|
|
||||||
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||||
|
|
||||||
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
|
||||||
where: { userId: testUser.id },
|
|
||||||
include: { attendees: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFindOriginalRescheduledBooking = vi.fn();
|
const mockFindOriginalRescheduledBooking = vi.fn();
|
||||||
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
|
||||||
BookingRepository: vi.fn().mockImplementation(() => ({
|
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||||
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
||||||
})),
|
})),
|
||||||
@@ -76,7 +76,7 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
|
|||||||
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({
|
||||||
getQualifiedHostsService: vi.fn().mockReturnValue({
|
getQualifiedHostsService: vi.fn().mockReturnValue({
|
||||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||||
qualifiedRRHosts: [],
|
qualifiedRRHosts: [],
|
||||||
@@ -133,7 +133,7 @@ vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", (
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
|
||||||
ProfileRepository: {
|
ProfileRepository: {
|
||||||
findManyForUser: vi.fn().mockResolvedValue([]),
|
findManyForUser: vi.fn().mockResolvedValue([]),
|
||||||
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import type { User } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import handler from "../../../pages/api/selected-calendars/_post";
|
import handler from "../../../pages/api/selected-calendars/_post";
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
|
|
||||||
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||||
id: 444444,
|
id: 444444,
|
||||||
} as any);
|
} as User);
|
||||||
|
|
||||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||||
credentialId: 1,
|
credentialId: 1,
|
||||||
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
unwatchAttempts: 0,
|
unwatchAttempts: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
channelId: null,
|
||||||
|
channelKind: null,
|
||||||
|
channelResourceId: null,
|
||||||
|
channelResourceUri: null,
|
||||||
|
channelExpiration: null,
|
||||||
|
syncSubscribedAt: null,
|
||||||
|
syncToken: null,
|
||||||
|
syncedAt: null,
|
||||||
|
syncErrorAt: null,
|
||||||
|
syncErrorCount: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
unwatchAttempts: 0,
|
unwatchAttempts: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
channelId: null,
|
||||||
|
channelKind: null,
|
||||||
|
channelResourceId: null,
|
||||||
|
channelResourceUri: null,
|
||||||
|
channelExpiration: null,
|
||||||
|
syncSubscribedAt: null,
|
||||||
|
syncToken: null,
|
||||||
|
syncedAt: null,
|
||||||
|
syncErrorAt: null,
|
||||||
|
syncErrorCount: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
/**
|
||||||
|
* Unit Tests for POST /api/users
|
||||||
|
*
|
||||||
|
* These tests verify the API endpoint logic without touching the database.
|
||||||
|
* All dependencies (UserCreationService) are mocked.
|
||||||
|
*/
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
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";
|
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);
|
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 () => {
|
test("should throw 401 if not system-wide admin", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -34,7 +70,9 @@ describe("POST /api/users", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw a 400 if no email is provided", async () => {
|
test("should throw a 400 if no email is provided", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -47,7 +85,9 @@ describe("POST /api/users", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw a 400 if no username is provided", async () => {
|
test("should throw a 400 if no username is provided", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -60,15 +100,24 @@ describe("POST /api/users", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create user successfully", async () => {
|
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>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
username: "test",
|
username: "testuser123",
|
||||||
},
|
},
|
||||||
prisma: prismock,
|
|
||||||
});
|
});
|
||||||
req.isSystemWideAdmin = true;
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
@@ -76,57 +125,19 @@ describe("POST /api/users", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
const userQuery = await prismock.user.findFirst({
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
where: {
|
|
||||||
email: "test@example.com",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(userQuery).toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
username: "test",
|
username: "testuser123",
|
||||||
locked: false,
|
locked: false,
|
||||||
organizationId: null,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
test("should auto lock user if email is in watchlist", async () => {
|
const responseData = JSON.parse(res._getData());
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
expect(responseData.user).toEqual(
|
||||||
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(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
username: "test",
|
username: "testuser123",
|
||||||
locked: true,
|
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -12,6 +12,53 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
|||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
describe("isAdmin guard", () => {
|
describe("isAdmin guard", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const acmeOrg = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: "acme",
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acmeOrg) {
|
||||||
|
await prisma.organizationSettings.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
orgAutoAcceptEmail: "acme.com",
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dunderOrg = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: "dunder-mifflin",
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dunderOrg) {
|
||||||
|
await prisma.organizationSettings.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId: dunderOrg.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isAdminAPIEnabled: false,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: dunderOrg.id,
|
||||||
|
orgAutoAcceptEmail: "dunder-mifflin.com",
|
||||||
|
isAdminAPIEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
it("Returns false when user does not exist in the system", async () => {
|
it("Returns false when user does not exist in the system", async () => {
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,40 +1,52 @@
|
|||||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
import type { NextApiRequest } from "next";
|
||||||
|
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
import { describe, expect, it, beforeEach } from "vitest";
|
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||||
|
import type { WatchlistFeature } from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
|
||||||
import { WatchlistSeverity } from "@calcom/prisma/enums";
|
|
||||||
|
|
||||||
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
|
||||||
|
getWatchlistFeature: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("isLockedOrBlocked", () => {
|
describe("isLockedOrBlocked", () => {
|
||||||
beforeEach(async () => {
|
let mockWatchlistFeature: WatchlistFeature;
|
||||||
await prismock.watchlist.createMany({
|
|
||||||
data: [
|
beforeEach(() => {
|
||||||
{
|
const mockGlobalBlocking = {
|
||||||
type: "DOMAIN",
|
isBlocked: vi.fn(),
|
||||||
value: "spam.com",
|
};
|
||||||
createdById: 1,
|
const mockOrgBlocking = {
|
||||||
},
|
isBlocked: vi.fn(),
|
||||||
{
|
};
|
||||||
type: "DOMAIN",
|
|
||||||
value: "blocked.com",
|
mockWatchlistFeature = {
|
||||||
severity: WatchlistSeverity.CRITICAL,
|
globalBlocking: mockGlobalBlocking,
|
||||||
createdById: 1,
|
orgBlocking: mockOrgBlocking,
|
||||||
},
|
} as unknown as WatchlistFeature;
|
||||||
],
|
|
||||||
});
|
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false if no user in request", async () => {
|
it("should return false if no user in request", async () => {
|
||||||
const req = { userId: null, user: null } as any;
|
const req = { userId: null, user: null } as unknown as NextApiRequest;
|
||||||
const result = await isLockedOrBlocked(req);
|
const result = await isLockedOrBlocked(req);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false if user has no email", async () => {
|
it("should return false if user has no email", async () => {
|
||||||
const req = { userId: 123, user: { email: null } } as any;
|
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
|
||||||
const result = await isLockedOrBlocked(req);
|
const result = await isLockedOrBlocked(req);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return true if user is locked", async () => {
|
it("should return true if user is locked", async () => {
|
||||||
@@ -44,10 +56,12 @@ describe("isLockedOrBlocked", () => {
|
|||||||
locked: true,
|
locked: true,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
},
|
},
|
||||||
} as any;
|
} as unknown as NextApiRequest;
|
||||||
|
|
||||||
const result = await isLockedOrBlocked(req);
|
const result = await isLockedOrBlocked(req);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return true if user email domain is watchlisted", async () => {
|
it("should return true if user email domain is watchlisted", async () => {
|
||||||
@@ -57,10 +71,14 @@ describe("isLockedOrBlocked", () => {
|
|||||||
locked: false,
|
locked: false,
|
||||||
email: "test@blocked.com",
|
email: "test@blocked.com",
|
||||||
},
|
},
|
||||||
} as any;
|
} as unknown as NextApiRequest;
|
||||||
|
|
||||||
|
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
|
||||||
|
|
||||||
const result = await isLockedOrBlocked(req);
|
const result = await isLockedOrBlocked(req);
|
||||||
expect(result).toBe(true);
|
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 () => {
|
it("should return false if user is not locked and email domain is not watchlisted", async () => {
|
||||||
@@ -70,10 +88,14 @@ describe("isLockedOrBlocked", () => {
|
|||||||
locked: false,
|
locked: false,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
},
|
},
|
||||||
} as any;
|
} as unknown as NextApiRequest;
|
||||||
|
|
||||||
|
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: false });
|
||||||
|
|
||||||
const result = await isLockedOrBlocked(req);
|
const result = await isLockedOrBlocked(req);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle email domains case-insensitively", async () => {
|
it("should handle email domains case-insensitively", async () => {
|
||||||
@@ -83,9 +105,13 @@ describe("isLockedOrBlocked", () => {
|
|||||||
locked: false,
|
locked: false,
|
||||||
email: "test@BLOCKED.COM",
|
email: "test@BLOCKED.COM",
|
||||||
},
|
},
|
||||||
} as any;
|
} as unknown as NextApiRequest;
|
||||||
|
|
||||||
|
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
|
||||||
|
|
||||||
const result = await isLockedOrBlocked(req);
|
const result = await isLockedOrBlocked(req);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -8,6 +8,30 @@ import {
|
|||||||
} from "../../../lib/utils/retrieveScopedAccessibleUsers";
|
} from "../../../lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
describe("retrieveScopedAccessibleUsers tests", () => {
|
describe("retrieveScopedAccessibleUsers tests", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const acmeOrg = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: "acme",
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acmeOrg) {
|
||||||
|
await prisma.organizationSettings.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: acmeOrg.id,
|
||||||
|
orgAutoAcceptEmail: "acme.com",
|
||||||
|
isAdminAPIEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
describe("getAccessibleUsers", () => {
|
describe("getAccessibleUsers", () => {
|
||||||
it("Does not return members when only admin user ID is supplied", async () => {
|
it("Does not return members when only admin user ID is supplied", async () => {
|
||||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ lerna-debug.log*
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.appStore.example
|
!.env.appStore.example
|
||||||
/client/*
|
/client/*
|
||||||
|
/generated/prisma/*
|
||||||
|
|||||||
@@ -16,14 +16,8 @@ ENV DATABASE_URL=${DATABASE_URL}
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
# Build prisma schema and make sure that it is linked to v2 node_modules
|
|
||||||
RUN yarn workspace @calcom/api-v2 run generate-schemas
|
RUN yarn workspace @calcom/api-v2 run generate-schemas
|
||||||
RUN rm -rf apps/api/v2/node_modules
|
|
||||||
RUN yarn install
|
|
||||||
|
|
||||||
RUN yarn workspace @calcom/platform-libraries run build
|
RUN yarn workspace @calcom/platform-libraries run build
|
||||||
|
|
||||||
RUN yarn workspace @calcom/api-v2 run build
|
RUN yarn workspace @calcom/api-v2 run build
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const loadConfig = (): AppConfig => {
|
|||||||
db: {
|
db: {
|
||||||
readUrl: getEnv("DATABASE_READ_URL"),
|
readUrl: getEnv("DATABASE_READ_URL"),
|
||||||
writeUrl: getEnv("DATABASE_WRITE_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"),
|
redisUrl: getEnv("REDIS_URL"),
|
||||||
},
|
},
|
||||||
next: {
|
next: {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type AppConfig = {
|
|||||||
db: {
|
db: {
|
||||||
readUrl: string;
|
readUrl: string;
|
||||||
writeUrl: string;
|
writeUrl: string;
|
||||||
|
readPoolMax: number;
|
||||||
|
writePoolMax: number;
|
||||||
|
workerReadPoolMax: number;
|
||||||
|
workerWritePoolMax: number;
|
||||||
redisUrl: string;
|
redisUrl: string;
|
||||||
};
|
};
|
||||||
next: {
|
next: {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ 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_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 { 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 { 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 { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { BillingModule } from "@/modules/billing/billing.module";
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
@@ -35,6 +40,9 @@ import { Module } from "@nestjs/common";
|
|||||||
SchedulesModule_2024_04_15,
|
SchedulesModule_2024_04_15,
|
||||||
EventTypesModule_2024_06_14,
|
EventTypesModule_2024_06_14,
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
|
RegularBookingModule,
|
||||||
|
RecurringBookingModule,
|
||||||
|
InstantBookingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
@@ -49,6 +57,8 @@ import { Module } from "@nestjs/common";
|
|||||||
AppsRepository,
|
AppsRepository,
|
||||||
CalendarsRepository,
|
CalendarsRepository,
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
|
PrismaEventTypeRepository,
|
||||||
|
PrismaTeamRepository,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController_2024_04_15],
|
controllers: [BookingsController_2024_04_15],
|
||||||
})
|
})
|
||||||
|
|||||||
+225
-10
@@ -1,6 +1,7 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||||
|
import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input";
|
||||||
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
@@ -21,7 +22,8 @@ import { randomString } from "test/utils/randomString";
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
import { BookingResponse } from "@calcom/platform-libraries";
|
||||||
|
import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings";
|
||||||
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
|
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||||
import type { User } from "@calcom/prisma/client";
|
import type { User } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -40,8 +42,9 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
let user: User;
|
let user: User;
|
||||||
|
|
||||||
let eventTypeId: number;
|
let eventTypeId: number;
|
||||||
|
let recEventTypeId: number;
|
||||||
|
|
||||||
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
|
let createdBooking: RegularBookingCreateResult;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -81,6 +84,18 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
},
|
},
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const recEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `rec-bookings-2024-04-15-event-type-${randomString()}-${describe.name}`,
|
||||||
|
slug: `rec-bookings-2024-04-15-event-type-${randomString()}-${describe.name}`,
|
||||||
|
length: 60,
|
||||||
|
recurringEvent: { freq: 2, count: 4, interval: 1 },
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
recEventTypeId = recEventType.id;
|
||||||
|
|
||||||
eventTypeId = event.id;
|
eventTypeId = event.id;
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -131,8 +146,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
.send(body)
|
.send(body)
|
||||||
.expect(201)
|
.expect(201)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||||
response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||||
@@ -231,8 +245,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
.expect(201)
|
.expect(201)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||||
response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||||
@@ -289,8 +302,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
.send(body)
|
.send(body)
|
||||||
.expect(201)
|
.expect(201)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||||
response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||||
@@ -387,8 +399,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
.send(body)
|
.send(body)
|
||||||
.expect(201)
|
.expect(201)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||||
response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||||
@@ -408,6 +419,210 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create recurring bookings", async () => {
|
||||||
|
const bookingEventTypeId = recEventTypeId;
|
||||||
|
const bookingTimeZone = "Europe/London";
|
||||||
|
const bookingLanguage = "en";
|
||||||
|
const bookingHashedLink = "";
|
||||||
|
const bookingMetadata = {
|
||||||
|
timeFormat: "12",
|
||||||
|
meetingType: "organizer-phone",
|
||||||
|
};
|
||||||
|
const bookingResponses = {
|
||||||
|
name: "tester",
|
||||||
|
email: "tester@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
notes: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: CreateRecurringBookingInput_2024_04_15[] = [
|
||||||
|
{
|
||||||
|
start: "2040-06-21T09:30:00.000Z",
|
||||||
|
end: "2040-06-21T10:30:00.000Z",
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: bookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: "2040-06-27T09:30:00.000Z",
|
||||||
|
end: "2040-06-27T10:30:00.000Z",
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: bookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
recurringEventId: "test-recurring-event-id-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: "2040-06-04T09:30:00.000Z",
|
||||||
|
end: "2040-06-04T10:30:00.000Z",
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: bookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
recurringEventId: "test-recurring-event-id-3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: "2040-06-11T09:30:00.000Z",
|
||||||
|
end: "2040-06-11T10:30:00.000Z",
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: bookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
recurringEventId: "test-recurring-event-id-4",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings/recurring")
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<BookingResponse[]> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
responseBody.data.forEach((booking) => {
|
||||||
|
expect(booking.userPrimaryEmail).toBeDefined();
|
||||||
|
expect(booking.userPrimaryEmail).toEqual(userEmail);
|
||||||
|
expect(booking.id).toBeDefined();
|
||||||
|
expect(booking.uid).toBeDefined();
|
||||||
|
expect(booking.eventTypeId).toEqual(bookingEventTypeId);
|
||||||
|
expect(booking.user.timeZone).toEqual(bookingTimeZone);
|
||||||
|
expect(booking.metadata).toEqual(bookingMetadata);
|
||||||
|
expect(booking.responses).toEqual(bookingResponses);
|
||||||
|
expect(booking.creationSource).toEqual("API_V2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event type booking requires authentication", () => {
|
||||||
|
let eventTypeRequiringAuthenticationId: number;
|
||||||
|
let unauthorizedUser: User;
|
||||||
|
let unauthorizedUserApiKeyString: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `event-type-requiring-authentication-${randomString()}`,
|
||||||
|
slug: `event-type-requiring-authentication-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
bookingRequiresAuthentication: true,
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
|
||||||
|
|
||||||
|
const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
|
||||||
|
unauthorizedUser = await userRepositoryFixture.create({
|
||||||
|
email: unauthorizedUserEmail,
|
||||||
|
});
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||||
|
unauthorizedUserApiKeyString = keyString;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (unauthorizedUser) {
|
||||||
|
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can't be booked without credentials", async () => {
|
||||||
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
|
start: "2040-05-23T09:30:00.000Z",
|
||||||
|
end: "2040-05-23T10:30:00.000Z",
|
||||||
|
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
language: "en",
|
||||||
|
metadata: {},
|
||||||
|
hashedLink: "",
|
||||||
|
responses: {
|
||||||
|
name: "External Attendee",
|
||||||
|
email: "external@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can't be booked with unauthorized user credentials", async () => {
|
||||||
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
|
start: "2040-05-23T10:30:00.000Z",
|
||||||
|
end: "2040-05-23T11:30:00.000Z",
|
||||||
|
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
language: "en",
|
||||||
|
metadata: {},
|
||||||
|
hashedLink: "",
|
||||||
|
responses: {
|
||||||
|
name: "External Attendee",
|
||||||
|
email: "external@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be booked with event type owner credentials", async () => {
|
||||||
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
|
start: "2040-05-23T11:30:00.000Z",
|
||||||
|
end: "2040-05-23T12:30:00.000Z",
|
||||||
|
eventTypeId: eventTypeRequiringAuthenticationId,
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
language: "en",
|
||||||
|
metadata: {},
|
||||||
|
hashedLink: "",
|
||||||
|
responses: {
|
||||||
|
name: "External Attendee",
|
||||||
|
email: "external@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.id).toBeDefined();
|
||||||
|
|
||||||
|
if (responseBody.data.id) {
|
||||||
|
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
|||||||
@@ -7,10 +7,20 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
|
|||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
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 { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||||
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
|
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 { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
|
import {
|
||||||
|
AuthOptionalUser,
|
||||||
|
GetOptionalUser,
|
||||||
|
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
@@ -35,6 +45,8 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
UnauthorizedException,
|
||||||
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||||
@@ -45,11 +57,8 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants";
|
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 { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
handleNewRecurringBooking,
|
|
||||||
handleNewBooking,
|
|
||||||
BookingResponse,
|
BookingResponse,
|
||||||
HttpError,
|
HttpError,
|
||||||
handleInstantMeeting,
|
|
||||||
handleMarkNoShow,
|
handleMarkNoShow,
|
||||||
getAllUserBookings,
|
getAllUserBookings,
|
||||||
getBookingInfo,
|
getBookingInfo,
|
||||||
@@ -58,6 +67,7 @@ import {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
} from "@calcom/platform-libraries";
|
} from "@calcom/platform-libraries";
|
||||||
import { CreationSource } from "@calcom/platform-libraries";
|
import { CreationSource } from "@calcom/platform-libraries";
|
||||||
|
import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings";
|
||||||
import {
|
import {
|
||||||
GetBookingsInput_2024_04_15,
|
GetBookingsInput_2024_04_15,
|
||||||
CancelBookingInput_2024_04_15,
|
CancelBookingInput_2024_04_15,
|
||||||
@@ -109,7 +119,12 @@ export class BookingsController_2024_04_15 {
|
|||||||
private readonly apiKeyRepository: ApiKeysRepository,
|
private readonly apiKeyRepository: ApiKeysRepository,
|
||||||
private readonly platformBookingsService: PlatformBookingsService,
|
private readonly platformBookingsService: PlatformBookingsService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly usersService: UsersService
|
private readonly usersService: UsersService,
|
||||||
|
private readonly regularBookingService: RegularBookingService,
|
||||||
|
private readonly recurringBookingService: RecurringBookingService,
|
||||||
|
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||||
|
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||||
|
private readonly teamRepository: PrismaTeamRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@@ -162,8 +177,12 @@ export class BookingsController_2024_04_15 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("/:bookingUid/reschedule")
|
@Get("/:bookingUid/reschedule")
|
||||||
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
|
@UseGuards(OptionalApiAuthGuard)
|
||||||
const booking = await getBookingForReschedule(bookingUid);
|
async getBookingForReschedule(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@GetOptionalUser() user: AuthOptionalUser
|
||||||
|
): Promise<ApiResponse<unknown>> {
|
||||||
|
const booking = await getBookingForReschedule(bookingUid, user?.id);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||||
@@ -186,18 +205,21 @@ export class BookingsController_2024_04_15 {
|
|||||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||||
const { orgSlug, locationUrl } = body;
|
const { orgSlug, locationUrl } = body;
|
||||||
try {
|
try {
|
||||||
|
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
|
||||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||||
const booking = await handleNewBooking({
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
userId: bookingRequest.userId,
|
bookingMeta: {
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
forcedSlug: orgSlug,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
forcedSlug: orgSlug,
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (booking.userId && booking.uid && booking.startTime) {
|
if (booking.userId && booking.uid && booking.startTime) {
|
||||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||||
@@ -312,18 +334,19 @@ export class BookingsController_2024_04_15 {
|
|||||||
recurringEvent.recurringEventId = recurringEventId;
|
recurringEvent.recurringEventId = recurringEventId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||||
|
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
|
||||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
|
bookingData: body.map((booking) => ({ ...booking, creationSource: CreationSource.API_V2 })),
|
||||||
bookingData: bookingRequest.body,
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
noEmail: bookingRequest.body.noEmail,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createdBookings.forEach(async (booking) => {
|
createdBookings.forEach(async (booking) => {
|
||||||
@@ -351,14 +374,15 @@ export class BookingsController_2024_04_15 {
|
|||||||
@Body() body: CreateBookingInput_2024_04_15,
|
@Body() body: CreateBookingInput_2024_04_15,
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string,
|
@Headers(X_CAL_CLIENT_ID) clientId?: string,
|
||||||
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
|
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
|
||||||
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
): Promise<ApiResponse<InstantBookingCreateResult>> {
|
||||||
const oAuthClientId =
|
const oAuthClientId =
|
||||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||||
try {
|
try {
|
||||||
const instantMeeting = await handleInstantMeeting(
|
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||||
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
const instantMeeting = await this.instantBookingCreateService.createBooking({
|
||||||
);
|
bookingData: bookingReq.body,
|
||||||
|
});
|
||||||
|
|
||||||
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -435,6 +459,48 @@ export class BookingsController_2024_04_15 {
|
|||||||
return oAuthClientParams.platformClientId;
|
return oAuthClientParams.platformClientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
|
||||||
|
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
|
||||||
|
id: eventTypeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!eventType?.bookingRequiresAuthentication) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await this.getOwnerId(req);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"This event type requires authentication. Please provide valid credentials."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEventTypeOwner = eventType.userId === userId;
|
||||||
|
const isHost = eventType.hosts.some((host) => host.userId === userId);
|
||||||
|
const isTeamAdminOrOwner =
|
||||||
|
eventType.team?.members.some((member) => member.userId === userId) ?? false;
|
||||||
|
|
||||||
|
let isOrgAdminOrOwner = false;
|
||||||
|
if (eventType.team?.parentId) {
|
||||||
|
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
|
||||||
|
userId,
|
||||||
|
teamId: eventType.team.parentId,
|
||||||
|
});
|
||||||
|
isOrgAdminOrOwner = !!orgTeam;
|
||||||
|
} else if (eventType.team?.isOrganization) {
|
||||||
|
isOrgAdminOrOwner = isTeamAdminOrOwner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
|
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
|
||||||
const res = { ...DEFAULT_PLATFORM_PARAMS };
|
const res = { ...DEFAULT_PLATFORM_PARAMS };
|
||||||
|
|
||||||
@@ -494,7 +560,10 @@ export class BookingsController_2024_04_15 {
|
|||||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
|
async setPlatformAttendeesEmails(
|
||||||
|
requestBody: { responses?: { email?: string; guests?: string[] } },
|
||||||
|
oAuthClientId: string
|
||||||
|
): Promise<void> {
|
||||||
if (requestBody?.responses?.email) {
|
if (requestBody?.responses?.email) {
|
||||||
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
|
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
|
||||||
requestBody.responses.email,
|
requestBody.responses.email,
|
||||||
@@ -556,6 +625,9 @@ export class BookingsController_2024_04_15 {
|
|||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
|
if (err instanceof HttpException) {
|
||||||
|
throw new HttpException(err.getResponse(), err.getStatus());
|
||||||
|
}
|
||||||
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||||
throw new HttpException(error.message, 400);
|
throw new HttpException(error.message, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ApiPropertyOptional({ type: [Object] })
|
@ApiPropertyOptional({ type: [Object] })
|
||||||
allRecurringDates?: Record<string, string>[];
|
allRecurringDates?: { start: string; end: string | undefined }[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
|
||||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||||
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
|
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||||
@@ -15,7 +17,11 @@ 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_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 { 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 { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.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 { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { BillingModule } from "@/modules/billing/billing.module";
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
@@ -58,6 +64,9 @@ import { Module } from "@nestjs/common";
|
|||||||
TeamsEventTypesModule,
|
TeamsEventTypesModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
|
RegularBookingModule,
|
||||||
|
RecurringBookingModule,
|
||||||
|
InstantBookingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
@@ -65,9 +74,11 @@ import { Module } from "@nestjs/common";
|
|||||||
OAuthClientRepository,
|
OAuthClientRepository,
|
||||||
OAuthClientUsersService,
|
OAuthClientUsersService,
|
||||||
BookingsService_2024_08_13,
|
BookingsService_2024_08_13,
|
||||||
|
BookingGuestsService_2024_08_13,
|
||||||
InputBookingsService_2024_08_13,
|
InputBookingsService_2024_08_13,
|
||||||
OutputBookingsService_2024_08_13,
|
OutputBookingsService_2024_08_13,
|
||||||
OutputBookingReferencesService_2024_08_13,
|
OutputBookingReferencesService_2024_08_13,
|
||||||
|
OutputEventTypesService_2024_06_14,
|
||||||
BookingsRepository_2024_08_13,
|
BookingsRepository_2024_08_13,
|
||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
BookingSeatRepository,
|
BookingSeatRepository,
|
||||||
@@ -86,7 +97,7 @@ import { Module } from "@nestjs/common";
|
|||||||
BookingReferencesRepository_2024_08_13,
|
BookingReferencesRepository_2024_08_13,
|
||||||
CalVideoService,
|
CalVideoService,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController_2024_08_13],
|
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
|
||||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||||
})
|
})
|
||||||
export class BookingsModule_2024_08_13 {}
|
export class BookingsModule_2024_08_13 {}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||||
|
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||||
|
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||||
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { Controller, Post, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/bookings/:bookingUid/guests",
|
||||||
|
version: VERSION_2024_08_13_VALUE,
|
||||||
|
})
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@DocsTags("Bookings / Guests")
|
||||||
|
@ApiHeader({
|
||||||
|
name: "cal-api-version",
|
||||||
|
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
|
||||||
|
example: VERSION_2024_08_13,
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
export class BookingGuestsController_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("BookingGuestsController_2024_08_13");
|
||||||
|
|
||||||
|
constructor(private readonly bookingGuestsService: BookingGuestsService_2024_08_13) {}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Add guests to an existing booking",
|
||||||
|
description: `Add one or more guests to an existing booking.
|
||||||
|
|
||||||
|
**Email Notifications:**
|
||||||
|
When guests are added, the following notifications are sent (unless disabled by event type settings):
|
||||||
|
|
||||||
|
- **Organizer & Team Members:** Receive an "Add Guests" notification email informing them that new guests have been added to the booking.
|
||||||
|
|
||||||
|
- **New Guests:** Receive a "Scheduled Event" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.
|
||||||
|
|
||||||
|
- **Existing Guests:** Receive an "Add Guests" notification email informing them that additional guests have been added to the booking.
|
||||||
|
|
||||||
|
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
async addGuests(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Body() body: AddGuestsInput_2024_08_13,
|
||||||
|
@GetUser() user: ApiAuthGuardUser
|
||||||
|
): Promise<AddGuestsOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingGuestsService.addGuests(bookingUid, body, user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ import {
|
|||||||
@DocsTags("Bookings")
|
@DocsTags("Bookings")
|
||||||
@ApiHeader({
|
@ApiHeader({
|
||||||
name: "cal-api-version",
|
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,
|
example: VERSION_2024_08_13,
|
||||||
required: true,
|
required: true,
|
||||||
schema: {
|
schema: {
|
||||||
@@ -133,6 +133,10 @@ 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
|
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.
|
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||||
|
|
||||||
|
For event types that have SMS reminders workflow, you need to pass the attendee's phone number in the request body via \`attendee.phoneNumber\` (e.g., "+19876543210" in international format). This is an optional field, but becomes required when SMS reminders are enabled for the event type. For the complete attendee object structure, see the [attendee object](https://cal.com/docs/api-reference/v2/bookings/create-a-booking#body-attendee) documentation.
|
||||||
|
|
||||||
|
<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({
|
@ApiBody({
|
||||||
@@ -188,6 +192,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
|
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.
|
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(
|
async getBooking(
|
||||||
@@ -206,7 +212,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@UseGuards(BookingUidGuard)
|
@UseGuards(BookingUidGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all the recordings for the booking",
|
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> {
|
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
|
||||||
const recordings = await this.calVideoService.getRecordings(bookingUid);
|
const recordings = await this.calVideoService.getRecordings(bookingUid);
|
||||||
@@ -221,7 +230,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@UseGuards(BookingUidGuard)
|
@UseGuards(BookingUidGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all the transcripts download links for the booking",
|
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> {
|
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
|
||||||
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
|
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
|
||||||
@@ -236,7 +248,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Permissions([BOOKING_READ])
|
@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(
|
async getBookings(
|
||||||
@Query() queryParams: GetBookingsInput_2024_08_13,
|
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||||
@GetUser() user: ApiAuthGuardUser
|
@GetUser() user: ApiAuthGuardUser
|
||||||
@@ -263,7 +278,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reschedule a booking",
|
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({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
@@ -272,7 +290,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
|
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
|
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header.
|
||||||
|
|
||||||
If you are rescheduling 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
|
If you are rescheduling 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.`,
|
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.`,
|
||||||
@@ -304,10 +322,13 @@ export class BookingsController_2024_08_13 {
|
|||||||
summary: "Cancel a booking",
|
summary: "Cancel a booking",
|
||||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||||
|
|
||||||
|
\nCancelling normal bookings:
|
||||||
|
If the booking is not seated and not recurring, simply pass :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and optionally cancellationReason in the request body \`{"cancellationReason": "Will travel"}\`.
|
||||||
|
|
||||||
\nCancelling seated bookings:
|
\nCancelling seated bookings:
|
||||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||||
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||||
|
|
||||||
\nCancelling recurring seated bookings:
|
\nCancelling recurring seated bookings:
|
||||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||||
@@ -315,6 +336,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
|
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.
|
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({
|
@ApiBody({
|
||||||
@@ -325,7 +348,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"Accepts different types of cancel booking input: Cancel Booking (Option 1) or Cancel Seated Booking (Option 2)",
|
"Accepts different types of cancel booking input: Cancel Booking (Option 1 which is for normal or recurring bookings) or Cancel Seated Booking (Option 2 which is for seated bookings)",
|
||||||
})
|
})
|
||||||
@ApiExtraModels(CancelBookingInput_2024_08_13, CancelSeatedBookingInput_2024_08_13)
|
@ApiExtraModels(CancelBookingInput_2024_08_13, CancelSeatedBookingInput_2024_08_13)
|
||||||
async cancelBooking(
|
async cancelBooking(
|
||||||
@@ -350,7 +373,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Mark a booking absence",
|
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(
|
async markNoShow(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -372,8 +398,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reassign a booking to auto-selected host",
|
summary: "Reassign a booking to auto-selected host",
|
||||||
description:
|
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||||
"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(
|
async reassignBooking(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -394,8 +422,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reassign a booking to a specific host",
|
summary: "Reassign a booking to a specific host",
|
||||||
description:
|
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||||
"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(
|
async reassignBookingToUser(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -423,7 +453,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Confirm a booking",
|
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(
|
async confirmBooking(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -444,7 +477,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Decline a booking",
|
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(
|
async declineBooking(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -465,8 +501,10 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get 'Add to Calendar' links for a booking",
|
summary: "Get 'Add to Calendar' links for a booking",
|
||||||
description:
|
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.
|
||||||
"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)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise<CalendarLinksOutput_2024_08_13> {
|
async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise<CalendarLinksOutput_2024_08_13> {
|
||||||
@@ -485,6 +523,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get booking references",
|
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)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getBookingReferences(
|
async getBookingReferences(
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { randomString } from "test/utils/randomString";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CAL_API_VERSION_HEADER,
|
||||||
|
ERROR_STATUS,
|
||||||
|
SUCCESS_STATUS,
|
||||||
|
VERSION_2024_08_13,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
AttendeeAddGuestsEmail,
|
||||||
|
AttendeeScheduledEmail,
|
||||||
|
OrganizerAddGuestsEmail,
|
||||||
|
} from "@calcom/platform-libraries/emails";
|
||||||
|
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import type { User, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
const attendeeAddGuestsEmailSpy = jest
|
||||||
|
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
|
||||||
|
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||||
|
const organizerAddGuestsEmailSpy = jest
|
||||||
|
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
|
||||||
|
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||||
|
const attendeeScheduledEmailSpy = jest
|
||||||
|
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
|
||||||
|
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||||
|
|
||||||
|
type TestUser = {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestSetup = {
|
||||||
|
organizer: TestUser;
|
||||||
|
unrelatedUser: TestUser;
|
||||||
|
guest: TestUser;
|
||||||
|
eventTypeId: number;
|
||||||
|
bookingUid: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13 add guests", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||||
|
|
||||||
|
let testSetup: TestSetup;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({
|
||||||
|
name: `user-emails-2024-08-13-organization-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupTestData();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
const oAuthClient = await createOAuthClient(organization.id, true);
|
||||||
|
|
||||||
|
const organizerUser = await userRepositoryFixture.create({
|
||||||
|
email: `user-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||||
|
platformOAuthClients: {
|
||||||
|
connect: { id: oAuthClient.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unrelatedUserData = await userRepositoryFixture.create({
|
||||||
|
email: `user-emails-2024-08-13-unrelated-${randomString()}@api.com`,
|
||||||
|
platformOAuthClients: {
|
||||||
|
connect: { id: oAuthClient.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const guestUser = await userRepositoryFixture.create({
|
||||||
|
email: `user-emails-2024-08-13-guest-${randomString()}@api.com`,
|
||||||
|
platformOAuthClients: {
|
||||||
|
connect: { id: oAuthClient.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
|
||||||
|
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
|
||||||
|
unrelatedUserData.id,
|
||||||
|
oAuthClient.id
|
||||||
|
);
|
||||||
|
const guestTokens = await tokensRepositoryFixture.createTokens(guestUser.id, oAuthClient.id);
|
||||||
|
|
||||||
|
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: `user-add-guests-2024-08-13-schedule-${randomString()}`,
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||||
|
|
||||||
|
const eventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||||
|
slug: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
organizerUser.id
|
||||||
|
);
|
||||||
|
|
||||||
|
testSetup = {
|
||||||
|
organizer: {
|
||||||
|
user: organizerUser,
|
||||||
|
accessToken: organizerTokens.accessToken,
|
||||||
|
refreshToken: organizerTokens.refreshToken,
|
||||||
|
},
|
||||||
|
unrelatedUser: {
|
||||||
|
user: unrelatedUserData,
|
||||||
|
accessToken: unrelatedUserTokens.accessToken,
|
||||||
|
refreshToken: unrelatedUserTokens.refreshToken,
|
||||||
|
},
|
||||||
|
guest: {
|
||||||
|
user: guestUser,
|
||||||
|
accessToken: guestTokens.accessToken,
|
||||||
|
refreshToken: guestTokens.refreshToken,
|
||||||
|
},
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
bookingUid: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
areEmailsEnabled: emailsEnabled,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
attendeeAddGuestsEmailSpy.mockClear();
|
||||||
|
organizerAddGuestsEmailSpy.mockClear();
|
||||||
|
attendeeScheduledEmailSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /v2/bookings/:bookingUid/guests", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: testSetup.eventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "100",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBookingResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||||
|
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
|
||||||
|
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testSetup.bookingUid = createBookingResponseBody.data.uid;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
it("should return 401 when adding guests without authentication", async () => {
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [{ email: "unauthenticated.guest@example.com" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuestsResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
|
expect(addGuestsResponse.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authorization - Organizer", () => {
|
||||||
|
it("should allow booking organizer to add multiple guests", async () => {
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [{ email: "organizer.guest1@example.com" }, { email: "organizer.guest2@example.com" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuestsResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||||
|
|
||||||
|
verifyAddGuestsResponse(
|
||||||
|
addGuestsResponseBody,
|
||||||
|
["organizer.guest1@example.com", "organizer.guest2@example.com"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when unrelated user tries to add guests", async () => {
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [
|
||||||
|
{ email: "non-organizer.guest1@example.com" },
|
||||||
|
{ email: "non-organizer.guest2@example.com" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authorization - guest", () => {
|
||||||
|
it("should allow organizer to add a user as guest, then guest can add more guests", async () => {
|
||||||
|
// Step 1: Organizer adds guest user as a guest
|
||||||
|
const addguestBody = {
|
||||||
|
guests: [{ email: testSetup.guest.user.email }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const addguestResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addguestBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const addguestResponseBody: AddGuestsOutput_2024_08_13 = addguestResponse.body;
|
||||||
|
|
||||||
|
verifyAddGuestsResponse(addguestResponseBody, [testSetup.guest.user.email], true);
|
||||||
|
|
||||||
|
// Step 2: Now the guest can add their own guests
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [{ email: "guest.guest1@example.com" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuestsResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.guest.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||||
|
|
||||||
|
verifyAddGuestsResponse(addGuestsResponseBody, ["guest.guest1@example.com"], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when non-guest user tries to add guests", async () => {
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [{ email: "non-guest.guest1@example.com" }, { email: "non-guest.guest2@example.com" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Validation", () => {
|
||||||
|
it("should return 400 when attempting to add duplicate guest email", async () => {
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [{ email: "organizer.guest1@example.com" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuestsResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`);
|
||||||
|
|
||||||
|
expect(addGuestsResponse.status).toEqual(400);
|
||||||
|
expect(addGuestsResponse.body.status).toEqual(ERROR_STATUS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /v2/bookings/:bookingUid/guests - Emails Disabled", () => {
|
||||||
|
let emailsDisabledSetup: TestSetup;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const oAuthClientDisabled = await createOAuthClient(organization.id, false);
|
||||||
|
|
||||||
|
const organizerUser = await userRepositoryFixture.create({
|
||||||
|
email: `user-no-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||||
|
platformOAuthClients: {
|
||||||
|
connect: { id: oAuthClientDisabled.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizerTokens = await tokensRepositoryFixture.createTokens(
|
||||||
|
organizerUser.id,
|
||||||
|
oAuthClientDisabled.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: `user-no-emails-2024-08-13-schedule-${randomString()}`,
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||||
|
|
||||||
|
const eventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||||
|
slug: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
organizerUser.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
attendee: {
|
||||||
|
name: "No Email User",
|
||||||
|
email: "no.email.user@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/no-emails",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "noEmailValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "200",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBookingResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||||
|
|
||||||
|
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
emailsDisabledSetup = {
|
||||||
|
organizer: {
|
||||||
|
user: organizerUser,
|
||||||
|
accessToken: organizerTokens.accessToken,
|
||||||
|
refreshToken: organizerTokens.refreshToken,
|
||||||
|
},
|
||||||
|
unrelatedUser: testSetup.unrelatedUser,
|
||||||
|
guest: testSetup.guest,
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
bookingUid: createBookingResponseBody.data.uid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT send emails when adding guests (emails disabled)", async () => {
|
||||||
|
attendeeAddGuestsEmailSpy.mockClear();
|
||||||
|
organizerAddGuestsEmailSpy.mockClear();
|
||||||
|
attendeeScheduledEmailSpy.mockClear();
|
||||||
|
|
||||||
|
const addGuestsBody = {
|
||||||
|
guests: [{ email: "no-email.guest1@example.com" }, { email: "no-email.guest2@example.com" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGuestsResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${emailsDisabledSetup.bookingUid}/guests`)
|
||||||
|
.send(addGuestsBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${emailsDisabledSetup.organizer.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||||
|
|
||||||
|
verifyAddGuestsResponse(
|
||||||
|
addGuestsResponseBody,
|
||||||
|
["no-email.guest1@example.com", "no-email.guest2@example.com"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.organizer.user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(
|
||||||
|
emailsDisabledSetup.organizer.user.id,
|
||||||
|
emailsDisabledSetup.organizer.user.email
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|
||||||
|
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(testSetup.guest.user.email);
|
||||||
|
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(
|
||||||
|
testSetup.organizer.user.id,
|
||||||
|
testSetup.organizer.user.email
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAddGuestsResponse(
|
||||||
|
responseBody: AddGuestsOutput_2024_08_13,
|
||||||
|
expectedGuestEmails: string[],
|
||||||
|
shouldEmailsBeSent: boolean
|
||||||
|
): BookingOutput_2024_08_13 {
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
|
||||||
|
if (!responseDataIsBooking(responseBody.data)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingData = responseBody.data;
|
||||||
|
|
||||||
|
expect(bookingData.guests).toBeDefined();
|
||||||
|
const guestEmails = bookingData.guests;
|
||||||
|
|
||||||
|
expectedGuestEmails.forEach((email) => {
|
||||||
|
expect(guestEmails).toContain(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bookingData.guests).toBeDefined();
|
||||||
|
expectedGuestEmails.forEach((email) => {
|
||||||
|
expect(bookingData.guests).toContain(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldEmailsBeSent) {
|
||||||
|
expect(attendeeAddGuestsEmailSpy).toHaveBeenCalled();
|
||||||
|
expect(organizerAddGuestsEmailSpy).toHaveBeenCalled();
|
||||||
|
expect(attendeeScheduledEmailSpy).toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
expect(attendeeAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||||
|
expect(organizerAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||||
|
expect(attendeeScheduledEmailSpy).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookingData;
|
||||||
|
}
|
||||||
|
});
|
||||||
+116
@@ -70,6 +70,7 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
|
|
||||||
let firstManagedUserEventTypeId: number;
|
let firstManagedUserEventTypeId: number;
|
||||||
let eventTypeRequiresConfirmationId: number;
|
let eventTypeRequiresConfirmationId: number;
|
||||||
|
let seatedEventTypeId: number;
|
||||||
|
|
||||||
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
|
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
|
||||||
let orgAdminManagedUser: CreateManagedUserData;
|
let orgAdminManagedUser: CreateManagedUserData;
|
||||||
@@ -309,6 +310,20 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create a seated event type for first managed user", async () => {
|
||||||
|
const seatedEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `managed-user-bookings-seated-event-type-${randomString()}`,
|
||||||
|
slug: `managed-user-bookings-seated-event-type-${randomString()}`,
|
||||||
|
length: 30,
|
||||||
|
seatsPerTimeSlot: 5,
|
||||||
|
seatsShowAttendees: true,
|
||||||
|
},
|
||||||
|
firstManagedUser.user.id
|
||||||
|
);
|
||||||
|
seatedEventTypeId = seatedEventType.id;
|
||||||
|
});
|
||||||
|
|
||||||
describe("bookings using original emails", () => {
|
describe("bookings using original emails", () => {
|
||||||
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
||||||
const body: CreateBookingInput_2024_08_13 = {
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
@@ -773,6 +788,107 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("seated booking management by org admin", () => {
|
||||||
|
let seatedBookingUid: string;
|
||||||
|
|
||||||
|
it("should create a seated booking with multiple attendees", async () => {
|
||||||
|
const bodyOne: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: seatedEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: secondManagedUser.user.name!,
|
||||||
|
email: secondManagedUser.user.email,
|
||||||
|
timeZone: secondManagedUser.user.timeZone,
|
||||||
|
language: secondManagedUser.user.locale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseOne = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(bodyOne)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBodyForFirstAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseOne.body;
|
||||||
|
expect(responseBodyForFirstAttendee.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBodyForFirstAttendee.data).toBeDefined();
|
||||||
|
|
||||||
|
const bookingDataForFirstAttendee = responseBodyForFirstAttendee.data as BookingOutput_2024_08_13;
|
||||||
|
seatedBookingUid = bookingDataForFirstAttendee.uid;
|
||||||
|
expect(bookingDataForFirstAttendee.attendees).toBeDefined();
|
||||||
|
expect(bookingDataForFirstAttendee.attendees.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const bodyTwo: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: seatedEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: thirdManagedUser.user.name!,
|
||||||
|
email: thirdManagedUser.user.email,
|
||||||
|
timeZone: thirdManagedUser.user.timeZone,
|
||||||
|
language: thirdManagedUser.user.locale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseTwo = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(bodyTwo)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBodyForSecondAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseTwo.body;
|
||||||
|
expect(responseBodyForSecondAttendee.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBodyForSecondAttendee.data).toBeDefined();
|
||||||
|
|
||||||
|
const bookingDataForSecondAttendee = responseBodyForSecondAttendee.data as BookingOutput_2024_08_13;
|
||||||
|
expect(bookingDataForSecondAttendee.attendees).toBeDefined();
|
||||||
|
expect(bookingDataForSecondAttendee.attendees.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow org admin to reschedule all seats in a seated booking for a managed user", async () => {
|
||||||
|
const newStartTime = new Date(Date.UTC(2030, 0, 10, 11, 0, 0)).toISOString();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${seatedBookingUid}/reschedule`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||||
|
.send({
|
||||||
|
start: newStartTime,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<BookingOutput_2024_08_13> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||||
|
expect(bookingData.start).toEqual(newStartTime);
|
||||||
|
|
||||||
|
seatedBookingUid = bookingData.uid;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow org admin to cancel all seats in a seated booking for a managed user", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${seatedBookingUid}/cancel`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||||
|
.send({
|
||||||
|
cancellationReason: "Org admin cancelled the booking",
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||||
|
expect(bookingData.status).toEqual("cancelled");
|
||||||
|
expect(bookingData.uid).toEqual(seatedBookingUid);
|
||||||
|
|
||||||
|
const cancelledBooking = await bookingsRepositoryFixture.getByUid(seatedBookingUid);
|
||||||
|
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("event type booking requires authentication", () => {
|
describe("event type booking requires authentication", () => {
|
||||||
let eventTypeRequiringAuthenticationId: number;
|
let eventTypeRequiringAuthenticationId: number;
|
||||||
|
|
||||||
|
|||||||
+423
-4
@@ -23,10 +23,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
|||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
|
||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
describe("Reassign bookings", () => {
|
describe("Reassign bookings", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@@ -47,12 +50,23 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
|
||||||
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
|
||||||
|
const teamUserEmail3 = `reassign-bookings-2024-08-13-user3-${randomString()}@api.com`;
|
||||||
let teamUser1: User;
|
let teamUser1: User;
|
||||||
let teamUser2: User;
|
let teamUser2: User;
|
||||||
|
let teamUser3: User;
|
||||||
|
|
||||||
let teamRoundRobinEventTypeId: number;
|
let teamRoundRobinEventTypeId: number;
|
||||||
|
let teamRoundRobinFixedHostEventTypeId: number;
|
||||||
|
let teamRoundRobinNonFixedEventTypeId: number;
|
||||||
|
let teamRoundRobinWithRescheduleReasonEventTypeId: number;
|
||||||
|
|
||||||
|
let teamRoundRobinNonFixedEventTypeTitle: string;
|
||||||
|
let teamRoundRobinFixedHostEventTypeTitle: string;
|
||||||
|
let teamRoundRobinWithRescheduleReasonEventTypeTitle: string;
|
||||||
|
|
||||||
let roundRobinBooking: Booking;
|
let roundRobinBooking: Booking;
|
||||||
|
let rescheduleReasonBookingUid: string;
|
||||||
|
let rescheduleReasonBookingInitialHostId: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -96,23 +110,37 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
teamUser1 = await userRepositoryFixture.create({
|
teamUser1 = await userRepositoryFixture.create({
|
||||||
email: teamUserEmail,
|
email: teamUserEmail,
|
||||||
locale: "it",
|
locale: "en",
|
||||||
name: `reassign-bookings-2024-08-13-user1-${randomString()}`,
|
name: `reassign-bookings-2024-08-13-user1-${randomString()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
teamUser2 = await userRepositoryFixture.create({
|
teamUser2 = await userRepositoryFixture.create({
|
||||||
email: teamUserEmail2,
|
email: teamUserEmail2,
|
||||||
locale: "it",
|
locale: "en",
|
||||||
name: `reassign-bookings-2024-08-13-user2-${randomString()}`,
|
name: `reassign-bookings-2024-08-13-user2-${randomString()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
teamUser3 = await userRepositoryFixture.create({
|
||||||
|
email: teamUserEmail3,
|
||||||
|
locale: "en",
|
||||||
|
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
|
||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
availabilities: [
|
||||||
|
{
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6], // All days of the week
|
||||||
|
startTime: new Date(Date.UTC(2024, 0, 1, 0, 0, 0)), // 00:00 UTC
|
||||||
|
endTime: new Date(Date.UTC(2024, 0, 1, 23, 59, 0)), // 23:59 UTC
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
|
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
|
||||||
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||||
|
await schedulesService.createUserSchedule(teamUser3.id, userSchedule);
|
||||||
|
|
||||||
await profileRepositoryFixture.create({
|
await profileRepositoryFixture.create({
|
||||||
uid: `usr-${teamUser1.id}`,
|
uid: `usr-${teamUser1.id}`,
|
||||||
@@ -144,6 +172,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${teamUser3.id}`,
|
||||||
|
username: teamUserEmail3,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser3.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await membershipsRepositoryFixture.create({
|
await membershipsRepositoryFixture.create({
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
team: { connect: { id: team.id } },
|
team: { connect: { id: team.id } },
|
||||||
@@ -158,6 +201,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
user: { connect: { id: teamUser3.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
schedulingType: "ROUND_ROBIN",
|
schedulingType: "ROUND_ROBIN",
|
||||||
team: {
|
team: {
|
||||||
@@ -230,12 +280,187 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
create: {
|
create: {
|
||||||
email: "bob@gmail.com",
|
email: "bob@gmail.com",
|
||||||
name: "Bob",
|
name: "Bob",
|
||||||
locale: "it",
|
locale: "en",
|
||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const teamNonFixedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "ROUND_ROBIN",
|
||||||
|
team: {
|
||||||
|
connect: { id: team.id },
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
connect: [{ id: teamUser2.id }, { id: teamUser3.id }],
|
||||||
|
},
|
||||||
|
title: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
|
||||||
|
slug: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: false,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
teamRoundRobinNonFixedEventTypeId = teamNonFixedEventType.id;
|
||||||
|
teamRoundRobinNonFixedEventTypeTitle = teamNonFixedEventType.title;
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: teamNonFixedEventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser3.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: teamNonFixedEventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "ROUND_ROBIN",
|
||||||
|
team: {
|
||||||
|
connect: { id: team.id },
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
connect: [{ id: teamUser1.id }, { id: teamUser2.id }, { id: teamUser3.id }],
|
||||||
|
},
|
||||||
|
title: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
|
||||||
|
slug: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: false,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
teamRoundRobinFixedHostEventTypeId = team2EventType.id;
|
||||||
|
teamRoundRobinFixedHostEventTypeTitle = team2EventType.title;
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: true,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser1.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team2EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team2EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser3.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team2EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamEventTypeWithRescheduleReason = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "ROUND_ROBIN",
|
||||||
|
team: {
|
||||||
|
connect: { id: team.id },
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
|
||||||
|
},
|
||||||
|
title: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
|
||||||
|
slug: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: false,
|
||||||
|
bookingFields: [
|
||||||
|
{
|
||||||
|
name: "rescheduleReason",
|
||||||
|
type: "textarea",
|
||||||
|
defaultLabel: "Reason for rescheduling",
|
||||||
|
required: true,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
type: "default",
|
||||||
|
label: "Default",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "system",
|
||||||
|
views: [
|
||||||
|
{
|
||||||
|
id: "reschedule",
|
||||||
|
label: "Reschedule View",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
teamRoundRobinWithRescheduleReasonEventTypeId = teamEventTypeWithRescheduleReason.id;
|
||||||
|
teamRoundRobinWithRescheduleReasonEventTypeTitle = teamEventTypeWithRescheduleReason.title;
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser1.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: teamEventTypeWithRescheduleReason.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: teamEventTypeWithRescheduleReason.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -307,6 +532,198 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should have correct title when reassigning round robin booking with non-fixed host", async () => {
|
||||||
|
const nonFixedHostBookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2050, 0, 9, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: teamRoundRobinNonFixedEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Charlie",
|
||||||
|
email: "charlie@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(nonFixedHostBookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const bookingUid = createResponse.body.data.uid;
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
|
||||||
|
expect(booking).toBeDefined();
|
||||||
|
expect(booking?.userId).toEqual(teamUser2.id);
|
||||||
|
|
||||||
|
const expectedInitialTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser2.name} and Charlie`;
|
||||||
|
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||||
|
expect(data.bookingUid).toEqual(bookingUid);
|
||||||
|
expect(data.reassignedTo.id).toEqual(teamUser3.id);
|
||||||
|
|
||||||
|
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
expect(reassigned?.userId).toEqual(teamUser3.id);
|
||||||
|
|
||||||
|
const expectedReassignedTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser3.name} and Charlie`;
|
||||||
|
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct title when reassigning round robin booking with fixed host", async () => {
|
||||||
|
const fixedHostBookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: teamRoundRobinFixedHostEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(fixedHostBookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const bookingUid = createResponse.body.data.uid;
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
|
||||||
|
expect(booking).toBeDefined();
|
||||||
|
expect(booking?.userId).toEqual(teamUser1.id);
|
||||||
|
|
||||||
|
const expectedInitialTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
|
||||||
|
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||||
|
expect(data.bookingUid).toEqual(bookingUid);
|
||||||
|
expect(data.reassignedTo.id).toEqual(teamUser1.id);
|
||||||
|
|
||||||
|
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
expect(reassigned?.userId).toEqual(teamUser1.id);
|
||||||
|
|
||||||
|
const expectedReassignedTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
|
||||||
|
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve attendee name when reassigning round robin host manually with rescheduleReason required", async () => {
|
||||||
|
const bookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2050, 0, 10, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: teamRoundRobinWithRescheduleReasonEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "David",
|
||||||
|
email: "david@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(bookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const bookingUid = createResponse.body.data.uid;
|
||||||
|
rescheduleReasonBookingUid = bookingUid;
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
|
||||||
|
expect(booking).toBeDefined();
|
||||||
|
expect(booking?.userId).toBeDefined();
|
||||||
|
const initialHostId = booking!.userId!;
|
||||||
|
rescheduleReasonBookingInitialHostId = initialHostId;
|
||||||
|
|
||||||
|
const expectedInitialTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${
|
||||||
|
booking?.userId === teamUser1.id ? teamUser1.name : teamUser2.name
|
||||||
|
} and David`;
|
||||||
|
expect(booking?.title).toEqual(expectedInitialTitle);
|
||||||
|
expect(booking?.title).not.toContain("Nameless");
|
||||||
|
|
||||||
|
const reassignToHostId = initialHostId === teamUser1.id ? teamUser2.id : teamUser1.id;
|
||||||
|
const reassignToHostName = reassignToHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||||
|
expect(data.bookingUid).toEqual(bookingUid);
|
||||||
|
expect(data.reassignedTo.id).toEqual(reassignToHostId);
|
||||||
|
|
||||||
|
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
expect(reassigned?.userId).toEqual(reassignToHostId);
|
||||||
|
|
||||||
|
const expectedReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${reassignToHostName} and David`;
|
||||||
|
expect(reassigned?.title).toEqual(expectedReassignedTitle);
|
||||||
|
expect(reassigned?.title).not.toContain("Nameless");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve attendee name when reassigning round robin host automatically with rescheduleReason required", async () => {
|
||||||
|
const bookingUid = rescheduleReasonBookingUid;
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
|
||||||
|
expect(booking).toBeDefined();
|
||||||
|
expect(booking?.userId).toBeDefined();
|
||||||
|
|
||||||
|
const currentHostId = booking!.userId!;
|
||||||
|
const initialHostId = rescheduleReasonBookingInitialHostId;
|
||||||
|
const initialHostName = initialHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
|
||||||
|
|
||||||
|
expect(currentHostId).not.toEqual(initialHostId);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${bookingUid}/reassign`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||||
|
expect(data.bookingUid).toEqual(bookingUid);
|
||||||
|
expect(data.reassignedTo.id).toEqual(initialHostId);
|
||||||
|
|
||||||
|
const autoReassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
|
||||||
|
expect(autoReassigned?.userId).toEqual(initialHostId);
|
||||||
|
|
||||||
|
const expectedAutoReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${initialHostName} and David`;
|
||||||
|
expect(autoReassigned?.title).toEqual(expectedAutoReassignedTitle);
|
||||||
|
expect(autoReassigned?.title).not.toContain("Nameless");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
@@ -325,9 +742,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
await userRepositoryFixture.deleteByEmail(teamUser1.email);
|
await userRepositoryFixture.deleteByEmail(teamUser1.email);
|
||||||
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||||
|
await userRepositoryFixture.deleteByEmail(teamUserEmail3);
|
||||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
|
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
|
||||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(teamUser3.id, teamUser3.email);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -65,6 +65,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let team1EventTypeId: number;
|
let team1EventTypeId: number;
|
||||||
let team2EventTypeId: number;
|
let team2EventTypeId: number;
|
||||||
let phoneOnlyEventTypeId: number;
|
let phoneOnlyEventTypeId: number;
|
||||||
|
let collectiveEventWithoutHostsId: number;
|
||||||
|
let roundRobinEventWithoutHostsId: number;
|
||||||
|
|
||||||
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||||
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
|
||||||
@@ -223,6 +225,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
team1EventTypeId = team1EventType.id;
|
team1EventTypeId = team1EventType.id;
|
||||||
|
|
||||||
|
const team1CollectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: {
|
||||||
|
connect: { id: team1.id },
|
||||||
|
},
|
||||||
|
title: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
|
||||||
|
slug: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
});
|
||||||
|
collectiveEventWithoutHostsId = team1CollectiveEventTypeWithoutHosts.id;
|
||||||
|
|
||||||
|
const team1RoundRobinEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "ROUND_ROBIN",
|
||||||
|
team: {
|
||||||
|
connect: { id: team1.id },
|
||||||
|
},
|
||||||
|
title: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
|
||||||
|
slug: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
});
|
||||||
|
roundRobinEventWithoutHostsId = team1RoundRobinEventTypeWithoutHosts.id;
|
||||||
|
|
||||||
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
schedulingType: "ROUND_ROBIN",
|
schedulingType: "ROUND_ROBIN",
|
||||||
team: {
|
team: {
|
||||||
@@ -363,6 +387,52 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cant book event types without hosts", () => {
|
||||||
|
it("should fail to book a collective event type without hosts", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: collectiveEventWithoutHostsId,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${collectiveEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail to book a round robin event type without hosts", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: roundRobinEventWithoutHostsId,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${roundRobinEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("create team bookings", () => {
|
describe("create team bookings", () => {
|
||||||
it("should create a team 1 booking", async () => {
|
it("should create a team 1 booking", async () => {
|
||||||
const body: CreateBookingInput_2024_08_13 = {
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
@@ -765,8 +835,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -2494,7 +2494,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(response.body.error.message).toEqual(
|
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 (
|
return (
|
||||||
!Array.isArray(data) &&
|
!Array.isArray(data) &&
|
||||||
typeof data === "object" &&
|
typeof data === "object" &&
|
||||||
|
data !== null &&
|
||||||
data &&
|
data &&
|
||||||
"id" in data &&
|
"id" in data &&
|
||||||
"recurringBookingUid" 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 {
|
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
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);
|
return Array.isArray(data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
export class AddGuestsOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
|
||||||
+2
-1
@@ -9,7 +9,7 @@ export class BookingReferencesRepository_2024_08_13 {
|
|||||||
constructor(private readonly dbRead: PrismaReadService) {}
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||||
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
|
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId, deleted: null };
|
||||||
|
|
||||||
if (filter?.type) {
|
if (filter?.type) {
|
||||||
whereClause.type = filter.type;
|
whereClause.type = filter.type;
|
||||||
@@ -30,6 +30,7 @@ export class BookingReferencesRepository_2024_08_13 {
|
|||||||
return this.dbRead.prisma.bookingReference.findFirst({
|
return this.dbRead.prisma.bookingReference.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: eventUid,
|
uid: eventUid,
|
||||||
|
deleted: null,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
credential: true,
|
credential: true,
|
||||||
+14
@@ -50,6 +50,20 @@ export class BookingsRepository_2024_08_13 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getByUidWithUserIdAndSeatsReferencesCount(bookingUid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid: bookingUid,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
seatsReferences: {
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getByUid(bookingUid: string) {
|
async getByUid(bookingUid: string) {
|
||||||
return this.dbRead.prisma.booking.findUnique({
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { Injectable, Logger, HttpException, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { addGuestsHandler } from "@calcom/platform-libraries/bookings";
|
||||||
|
import type { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingGuestsService_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("BookingGuestsService_2024_08_13");
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
|
private readonly bookingsService: BookingsService_2024_08_13,
|
||||||
|
private readonly platformBookingsService: PlatformBookingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async addGuests(bookingUid: string, input: AddGuestsInput_2024_08_13, user: ApiAuthGuardUser) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformClientParams = booking.eventTypeId
|
||||||
|
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||||
|
|
||||||
|
const res = await addGuestsHandler({
|
||||||
|
ctx: { user },
|
||||||
|
input: { bookingId: booking.id, guests: input.guests },
|
||||||
|
emailsEnabled,
|
||||||
|
});
|
||||||
|
if (res.message === "Guests added") {
|
||||||
|
return await this.bookingsService.getBooking(bookingUid, user);
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
"Failed to add guests to the booking",
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
@@ -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 { 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 { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
import { getPagination } from "@/lib/pagination/pagination";
|
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 { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
UnprocessableEntityException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -36,10 +40,8 @@ import { DateTime } from "luxon";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
handleNewRecurringBooking,
|
|
||||||
getTranslation,
|
getTranslation,
|
||||||
getAllUserBookings,
|
getAllUserBookings,
|
||||||
handleInstantMeeting,
|
|
||||||
handleCancelBooking,
|
handleCancelBooking,
|
||||||
roundRobinReassignment,
|
roundRobinReassignment,
|
||||||
roundRobinManualReassignment,
|
roundRobinManualReassignment,
|
||||||
@@ -47,7 +49,7 @@ import {
|
|||||||
confirmBookingHandler,
|
confirmBookingHandler,
|
||||||
getCalendarLinks,
|
getCalendarLinks,
|
||||||
} from "@calcom/platform-libraries";
|
} from "@calcom/platform-libraries";
|
||||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings";
|
||||||
import {
|
import {
|
||||||
CreateBookingInput_2024_08_13,
|
CreateBookingInput_2024_08_13,
|
||||||
CreateBookingInput,
|
CreateBookingInput,
|
||||||
@@ -63,6 +65,7 @@ import {
|
|||||||
RescheduleBookingInput,
|
RescheduleBookingInput,
|
||||||
CancelBookingInput,
|
CancelBookingInput,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
import type { EventType, User, Team } from "@calcom/prisma/client";
|
import type { EventType, User, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -108,7 +111,10 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||||
private readonly membershipsRepository: MembershipsRepository,
|
private readonly membershipsRepository: MembershipsRepository,
|
||||||
private readonly membershipsService: MembershipsService,
|
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) {
|
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||||
@@ -132,6 +138,10 @@ export class BookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType.schedulingType === "COLLECTIVE" || eventType.schedulingType === "ROUND_ROBIN") {
|
||||||
|
await this.checkEventTypeHasHosts(eventType.id);
|
||||||
|
}
|
||||||
|
|
||||||
body.eventTypeId = eventType.id;
|
body.eventTypeId = eventType.id;
|
||||||
|
|
||||||
if ("instant" in body && body.instant) {
|
if ("instant" in body && body.instant) {
|
||||||
@@ -159,6 +169,15 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkEventTypeHasHosts(eventTypeId: number) {
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeWithHosts(eventTypeId);
|
||||||
|
if (!eventType?.hosts?.length) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${eventTypeId} belonging to team with id=${eventType?.teamId} and try again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async checkBookingRequiresAuthenticationSetting(
|
async checkBookingRequiresAuthenticationSetting(
|
||||||
eventType: EventTypeWithOwnerAndTeam,
|
eventType: EventTypeWithOwnerAndTeam,
|
||||||
authUser: AuthOptionalUser,
|
authUser: AuthOptionalUser,
|
||||||
@@ -450,7 +469,9 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
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(
|
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||||
booking.bookingId
|
booking.bookingId
|
||||||
@@ -468,17 +489,19 @@ export class BookingsService_2024_08_13 {
|
|||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||||
const bookings = await handleNewRecurringBooking({
|
const bookings = await this.recurringBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
userId: bookingRequest.userId,
|
bookingMeta: {
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
noEmail: bookingRequest.noEmail,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
noEmail: bookingRequest.noEmail,
|
||||||
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const ids = bookings.map((booking) => booking.id || 0);
|
const ids = bookings.map((booking) => booking.id || 0);
|
||||||
return this.outputService.getOutputRecurringBookings(ids);
|
return this.outputService.getOutputRecurringBookings(ids);
|
||||||
@@ -491,16 +514,18 @@ export class BookingsService_2024_08_13 {
|
|||||||
userIsEventTypeAdminOrOwner: boolean
|
userIsEventTypeAdminOrOwner: boolean
|
||||||
) {
|
) {
|
||||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||||
const bookings = await handleNewRecurringBooking({
|
const bookings = await this.recurringBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
userId: bookingRequest.userId,
|
bookingMeta: {
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
|
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
|
||||||
@@ -514,16 +539,18 @@ export class BookingsService_2024_08_13 {
|
|||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
const booking = await handleNewBooking({
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
userId: bookingRequest.userId,
|
bookingMeta: {
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
@@ -546,16 +573,18 @@ export class BookingsService_2024_08_13 {
|
|||||||
) {
|
) {
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
try {
|
try {
|
||||||
const booking = await handleNewBooking({
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
userId: bookingRequest.userId,
|
bookingMeta: {
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
@@ -735,22 +764,34 @@ export class BookingsService_2024_08_13 {
|
|||||||
authUser: AuthOptionalUser
|
authUser: AuthOptionalUser
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await this.canRescheduleBooking(bookingUid);
|
const isIndividualSeatRequest = this.isRescheduleSeatedBody(body);
|
||||||
|
const isIndividualSeatReschedule = await this.shouldRescheduleIndividualSeat(
|
||||||
|
bookingUid,
|
||||||
|
isIndividualSeatRequest,
|
||||||
|
authUser
|
||||||
|
);
|
||||||
|
|
||||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||||
request,
|
request,
|
||||||
bookingUid,
|
bookingUid,
|
||||||
body
|
body,
|
||||||
|
isIndividualSeatReschedule
|
||||||
);
|
);
|
||||||
const booking = await handleNewBooking({
|
|
||||||
|
await this.canRescheduleBooking(bookingUid);
|
||||||
|
|
||||||
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
userId: bookingRequest.userId,
|
bookingMeta: {
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId ?? authUser?.id,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
throw new Error("Booking missing uid");
|
throw new Error("Booking missing uid");
|
||||||
@@ -811,6 +852,57 @@ export class BookingsService_2024_08_13 {
|
|||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async shouldRescheduleIndividualSeat(
|
||||||
|
bookingUid: string,
|
||||||
|
isIndividualSeatReschedule: boolean,
|
||||||
|
authUser: AuthOptionalUser
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithUserIdAndSeatsReferencesCount(bookingUid);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSeatsPresent = booking.seatsReferences.length > 0;
|
||||||
|
|
||||||
|
if (!hasSeatsPresent) return false;
|
||||||
|
|
||||||
|
return await this.isIndividualSeatOrOrgAdminReschedule(
|
||||||
|
isIndividualSeatReschedule,
|
||||||
|
booking.userId,
|
||||||
|
authUser?.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isIndividualSeatOrOrgAdminReschedule(
|
||||||
|
isIndividualSeatReschedule: boolean,
|
||||||
|
bookingUserId: number | null,
|
||||||
|
authUserId?: number | null
|
||||||
|
) {
|
||||||
|
if (isIndividualSeatReschedule) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authUserId) {
|
||||||
|
throw new Error(`No auth user found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookingUserId) {
|
||||||
|
throw new Error(`No user found for booking`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrgAdmin = await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
|
||||||
|
authUserId,
|
||||||
|
bookingUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
return isOrgAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||||
|
return "seatUid" in body;
|
||||||
|
}
|
||||||
|
|
||||||
async cancelBooking(
|
async cancelBooking(
|
||||||
request: Request,
|
request: Request,
|
||||||
bookingUid: string,
|
bookingUid: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 {
|
|||||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||||
} else if (error.message === "hosts_unavailable_for_booking") {
|
} else if (error.message === "hosts_unavailable_for_booking") {
|
||||||
throw new BadRequestException(hostsUnavaile);
|
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;
|
throw error;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import {
|
import {
|
||||||
eventTypeBookingFieldsSchema,
|
eventTypeBookingFieldsSchema,
|
||||||
EventTypeWithOwnerAndTeam,
|
EventTypeWithOwnerAndTeam,
|
||||||
@@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
async createRescheduleBookingRequest(
|
async createRescheduleBookingRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
bookingUid: string,
|
bookingUid: string,
|
||||||
body: RescheduleBookingInput
|
body: RescheduleBookingInput,
|
||||||
|
isIndividualSeatReschedule: boolean
|
||||||
): Promise<BookingRequest> {
|
): Promise<BookingRequest> {
|
||||||
const bodyTransformed = this.isRescheduleSeatedBody(body)
|
const bodyTransformed =
|
||||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
isIndividualSeatReschedule && "seatUid" in body
|
||||||
: await this.transformInputRescheduleBooking(bookingUid, body);
|
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||||
|
: await this.transformInputRescheduleBooking(bookingUid, body, isIndividualSeatReschedule);
|
||||||
|
|
||||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(
|
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(
|
||||||
bodyTransformed.eventTypeId
|
bodyTransformed.eventTypeId
|
||||||
@@ -554,7 +556,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||||
return body.hasOwnProperty("seatUid");
|
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||||
}
|
}
|
||||||
|
|
||||||
async transformInputRescheduleSeatedBooking(
|
async transformInputRescheduleSeatedBooking(
|
||||||
@@ -606,7 +608,11 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
async transformInputRescheduleBooking(
|
||||||
|
bookingUid: string,
|
||||||
|
inputBooking: RescheduleBookingInput_2024_08_13,
|
||||||
|
isIndividualSeatReschedule: boolean
|
||||||
|
) {
|
||||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||||
@@ -618,7 +624,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||||
}
|
}
|
||||||
if (eventType.seatsPerTimeSlot) {
|
if (eventType.seatsPerTimeSlot && !isIndividualSeatReschedule) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||||
);
|
);
|
||||||
@@ -652,7 +658,6 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||||
const endTime = startTime.plus({ minutes: eventType.length });
|
const endTime = startTime.plus({ minutes: eventType.length });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start: startTime.toISO(),
|
start: startTime.toISO(),
|
||||||
end: endTime.toISO(),
|
end: endTime.toISO(),
|
||||||
@@ -770,7 +775,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
||||||
return body.hasOwnProperty("seatUid");
|
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||||
}
|
}
|
||||||
|
|
||||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||||
@@ -817,7 +822,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.
|
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
|
||||||
return {
|
return {
|
||||||
uid: bookingUid,
|
uid: bookingUid,
|
||||||
cancellationReason: "",
|
cancellationReason: inputBooking.cancellationReason || "",
|
||||||
allRemainingBookings: false,
|
allRemainingBookings: false,
|
||||||
seatReferenceUid: inputBooking.seatUid,
|
seatReferenceUid: inputBooking.seatUid,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import {
|
import {
|
||||||
defaultBookingMetadata,
|
defaultBookingMetadata,
|
||||||
defaultBookingResponses,
|
defaultBookingResponses,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
|
|||||||
+5
-1
@@ -137,7 +137,11 @@ export class EventTypesService_2024_04_15 {
|
|||||||
!bookingFields.find((field) => field.type === "email") &&
|
!bookingFields.find((field) => field.type === "email") &&
|
||||||
!bookingFields.find((field) => field.type === "phone")
|
!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({
|
await updateEventType({
|
||||||
|
|||||||
+420
-26
@@ -22,26 +22,17 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
|||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||||
import {
|
import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, ConfirmationPolicyEnum, NoticeThresholdUnitEnum, FrequencyInput } from "@calcom/platform-enums";
|
||||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
|
||||||
BookerLayoutsInputEnum_2024_06_14,
|
|
||||||
ConfirmationPolicyEnum,
|
|
||||||
NoticeThresholdUnitEnum,
|
|
||||||
FrequencyInput,
|
|
||||||
} from "@calcom/platform-enums";
|
|
||||||
import { SchedulingType } from "@calcom/platform-libraries";
|
import { SchedulingType } from "@calcom/platform-libraries";
|
||||||
import type {
|
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";
|
||||||
ApiSuccessResponse,
|
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||||
CreateEventTypeInput_2024_06_14,
|
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||||
EventTypeOutput_2024_06_14,
|
|
||||||
GuestsDefaultFieldOutput_2024_06_14,
|
|
||||||
NameDefaultFieldInput_2024_06_14,
|
|
||||||
NotesDefaultFieldInput_2024_06_14,
|
|
||||||
UpdateEventTypeInput_2024_06_14,
|
|
||||||
} from "@calcom/platform-types";
|
|
||||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
|
||||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||||
if (a.slug < b.slug) return -1;
|
if (a.slug < b.slug) return -1;
|
||||||
if (a.slug > b.slug) return 1;
|
if (a.slug > b.slug) return 1;
|
||||||
@@ -1441,32 +1432,394 @@ describe("Event types Endpoints", () => {
|
|||||||
.expect(200);
|
.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 () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
try {
|
try {
|
||||||
await eventTypesRepositoryFixture.delete(eventType.id);
|
await eventTypesRepositoryFixture.delete(eventType.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Event type might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await userRepositoryFixture.delete(user.id);
|
await userRepositoryFixture.delete(user.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await userRepositoryFixture.delete(falseTestUser.id);
|
await userRepositoryFixture.delete(falseTestUser.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userRepositoryFixture.delete(orgUser.id);
|
await userRepositoryFixture.delete(orgUser.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("confirmationPolicy", () => {
|
||||||
|
describe("negative tests", () => {
|
||||||
|
it("should not create an event type with type 'time' without noticeThreshold", async () => {
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Event requiring confirmation with time",
|
||||||
|
slug: "event-confirmation-time-missing-threshold",
|
||||||
|
description: "This event requires confirmation based on time.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.TIME,
|
||||||
|
blockUnconfirmedBookingsInBooker: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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).toBeDefined();
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not update an event type to type 'time' without noticeThreshold", async () => {
|
||||||
|
// First create an event type with type 'always'
|
||||||
|
const createBody: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Event requiring confirmation always",
|
||||||
|
slug: "event-confirmation-always-to-time",
|
||||||
|
description: "This event always requires confirmation.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.ALWAYS,
|
||||||
|
blockUnconfirmedBookingsInBooker: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
|
.send(createBody)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createdEventType = createResponse.body.data;
|
||||||
|
|
||||||
|
// Try to update to type 'time' without noticeThreshold
|
||||||
|
const updateBody: UpdateEventTypeInput_2024_06_14 = {
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.TIME,
|
||||||
|
blockUnconfirmedBookingsInBooker: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResponse = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
|
.send(updateBody)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(updateResponse.body.error).toBeDefined();
|
||||||
|
expect(updateResponse.status).toBe(400);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("positive tests", () => {
|
||||||
|
let eventTypeWithConfirmationTimeId: number;
|
||||||
|
|
||||||
|
it("should create an event type with type 'always'", async () => {
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Event requiring confirmation always",
|
||||||
|
slug: "event-confirmation-always",
|
||||||
|
description: "This event always requires confirmation.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.ALWAYS,
|
||||||
|
blockUnconfirmedBookingsInBooker: 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.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||||
|
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an event type with type 'time' and noticeThreshold", async () => {
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Event requiring confirmation with time",
|
||||||
|
slug: "event-confirmation-time-with-threshold",
|
||||||
|
description: "This event requires confirmation based on time.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.TIME,
|
||||||
|
noticeThreshold: {
|
||||||
|
unit: NoticeThresholdUnitEnum.HOURS,
|
||||||
|
count: 24,
|
||||||
|
},
|
||||||
|
blockUnconfirmedBookingsInBooker: 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.confirmationPolicy).toEqual(body.confirmationPolicy);
|
||||||
|
eventTypeWithConfirmationTimeId = createdEventType.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update an event type from type 'time' to 'always'", async () => {
|
||||||
|
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.ALWAYS,
|
||||||
|
blockUnconfirmedBookingsInBooker: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
|
||||||
|
.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;
|
||||||
|
const policy = updatedEventType.confirmationPolicy as any;
|
||||||
|
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
|
||||||
|
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
|
||||||
|
expect(policy?.noticeThreshold).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update an event type from type 'always' to 'time' with noticeThreshold", async () => {
|
||||||
|
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||||
|
confirmationPolicy: {
|
||||||
|
type: ConfirmationPolicyEnum.TIME,
|
||||||
|
noticeThreshold: {
|
||||||
|
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||||
|
count: 120,
|
||||||
|
},
|
||||||
|
blockUnconfirmedBookingsInBooker: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
|
||||||
|
.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;
|
||||||
|
const policy = updatedEventType.confirmationPolicy as any;
|
||||||
|
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
|
||||||
|
expect(policy?.noticeThreshold).toEqual({
|
||||||
|
unit: NoticeThresholdUnitEnum.MINUTES,
|
||||||
|
count: 120,
|
||||||
|
});
|
||||||
|
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(true);
|
||||||
|
await eventTypesRepositoryFixture.delete(eventTypeWithConfirmationTimeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Handle event-types booking fields", () => {
|
describe("Handle event-types booking fields", () => {
|
||||||
@@ -2187,6 +2540,47 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("split name booking field", () => {
|
||||||
|
it("should create an event type with split name booking field", async () => {
|
||||||
|
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
|
||||||
|
isDefault: true,
|
||||||
|
type: "splitName",
|
||||||
|
slug: "splitName",
|
||||||
|
firstNameLabel: "First name",
|
||||||
|
firstNamePlaceholder: "",
|
||||||
|
lastNameLabel: "last name",
|
||||||
|
lastNamePlaceholder: "",
|
||||||
|
lastNameRequired: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Coding class 9",
|
||||||
|
slug: "coding-class-9",
|
||||||
|
description: "Let's learn how to code like a pro.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
bookingFields: [splitNameBookingField],
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
|
const createdEventType = responseBody.data;
|
||||||
|
|
||||||
|
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
|
||||||
|
(field) => field.type === "splitName"
|
||||||
|
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
|
||||||
|
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
|
||||||
|
|
||||||
|
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
@@ -2194,12 +2588,12 @@ describe("Event types Endpoints", () => {
|
|||||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
|
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Event type might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await userRepositoryFixture.delete(user.id);
|
await userRepositoryFixture.delete(user.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -2379,7 +2773,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
const updatedEventType = responseBody.data;
|
const updatedEventType = responseBody.data;
|
||||||
+expect(updatedEventType.hidden).toBe(false);
|
expect(updatedEventType.hidden).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2389,14 +2783,14 @@ describe("Event types Endpoints", () => {
|
|||||||
try {
|
try {
|
||||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Event type might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await userRepositoryFixture.delete(user.id);
|
await userRepositoryFixture.delete(user.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
console.log(e);
|
||||||
}
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+21
-7
@@ -61,7 +61,7 @@ import {
|
|||||||
@DocsTags("Event Types")
|
@DocsTags("Event Types")
|
||||||
@ApiHeader({
|
@ApiHeader({
|
||||||
name: "cal-api-version",
|
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,
|
example: VERSION_2024_06_14,
|
||||||
required: true,
|
required: true,
|
||||||
schema: {
|
schema: {
|
||||||
@@ -80,7 +80,10 @@ export class EventTypesController_2024_06_14 {
|
|||||||
@Permissions([EVENT_TYPE_WRITE])
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@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(
|
async createEventType(
|
||||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
@@ -102,7 +105,10 @@ export class EventTypesController_2024_06_14 {
|
|||||||
@Permissions([EVENT_TYPE_READ])
|
@Permissions([EVENT_TYPE_READ])
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@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(
|
async getEventTypeById(
|
||||||
@Param("eventTypeId") eventTypeId: string,
|
@Param("eventTypeId") eventTypeId: string,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
@@ -122,8 +128,10 @@ export class EventTypesController_2024_06_14 {
|
|||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all event types",
|
summary: "Get all event types",
|
||||||
description:
|
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
|
||||||
"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)
|
@UseGuards(OptionalApiAuthGuard)
|
||||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||||
@@ -149,7 +157,10 @@ export class EventTypesController_2024_06_14 {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@HttpCode(HttpStatus.OK)
|
@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(
|
async updateEventType(
|
||||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
@Body() body: UpdateEventTypeInput_2024_06_14,
|
@Body() body: UpdateEventTypeInput_2024_06_14,
|
||||||
@@ -173,7 +184,10 @@ export class EventTypesController_2024_06_14 {
|
|||||||
@Permissions([EVENT_TYPE_WRITE])
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@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(
|
async deleteEventType(
|
||||||
@Param("eventTypeId") eventTypeId: number,
|
@Param("eventTypeId") eventTypeId: number,
|
||||||
@GetUser("id") userId: number
|
@GetUser("id") userId: number
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user