Compare commits
231
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e481f2921 | ||
|
|
0e6203ac3c | ||
|
|
624d6d7001 | ||
|
|
ec03ab632e | ||
|
|
c81b8145b5 | ||
|
|
fa35cc5210 | ||
|
|
1eb6ff3e65 | ||
|
|
fc4ecf500a | ||
|
|
9d4522825b | ||
|
|
e91bf53d80 | ||
|
|
79b319c1dd | ||
|
|
9197a73b31 | ||
|
|
ec3656e646 | ||
|
|
ae4b807500 | ||
|
|
34a9872df1 | ||
|
|
91a480b83a | ||
|
|
db19bb3d51 | ||
|
|
9afdb1ad7e | ||
|
|
78c2a69389 | ||
|
|
5a483e75c9 | ||
|
|
bb9c4bb8f3 | ||
|
|
02fdc1361c | ||
|
|
573132198e | ||
|
|
9133bc5b16 | ||
|
|
94ae3a5a73 | ||
|
|
0757b00db7 | ||
|
|
3402f79912 | ||
|
|
56ef5d667c | ||
|
|
bb9d99c3e1 | ||
|
|
1aac14056e | ||
|
|
7af46cc7ab | ||
|
|
37cec89bf6 | ||
|
|
67843adab9 | ||
|
|
64297f027c | ||
|
|
9fe5ff7721 | ||
|
|
3076aca457 | ||
|
|
5a59bb86cd | ||
|
|
17ab088af8 | ||
|
|
b81a307bd9 | ||
|
|
b312955838 | ||
|
|
003aaaa18b | ||
|
|
3b19c064fd | ||
|
|
e26a57bf25 | ||
|
|
b5585b3974 | ||
|
|
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 | ||
|
|
a48a1bd5d8 | ||
|
|
1acd0a14ba | ||
|
|
6c9ac7636b | ||
|
|
d261a23dbc | ||
|
|
8b7947a172 | ||
|
|
89789b1e3b | ||
|
|
43dd24c480 | ||
|
|
ae6b1d402a | ||
|
|
3054ad33b2 | ||
|
|
a4dbb1a90b | ||
|
|
689150d78a | ||
|
|
fba096f23d | ||
|
|
7d5a3391f1 | ||
|
|
dcef99bf49 | ||
|
|
805fcc22a5 | ||
|
|
c5b7da8ffd | ||
|
|
97f1f7b130 | ||
|
|
0ac460fce3 | ||
|
|
82dfca8272 | ||
|
|
d1c5576d4f | ||
|
|
7090e3ed60 | ||
|
|
9926fd2e79 | ||
|
|
7793cb20d1 | ||
|
|
8ed0fb6a07 | ||
|
|
f5a1222fc5 | ||
|
|
2b892296b0 | ||
|
|
f796802ed3 | ||
|
|
b2ac227877 | ||
|
|
f0aec4efdc | ||
|
|
8d7cdf9d3b | ||
|
|
2ed0380e4f | ||
|
|
752cfa6bf9 | ||
|
|
f11c4dd7ec | ||
|
|
386f60ca6d | ||
|
|
e98f279f64 | ||
|
|
fe190f20f3 | ||
|
|
96855ea391 | ||
|
|
57150d992e | ||
|
|
8b75bf347f | ||
|
|
788fbdbe5e | ||
|
|
b2239374bc | ||
|
|
77c61abef8 | ||
|
|
c2472cc05a | ||
|
|
608fe1c586 | ||
|
|
4413295b27 | ||
|
|
bcc433ba9a | ||
|
|
2ba571d230 | ||
|
|
4a0a1ef053 | ||
|
|
7e882eedf8 | ||
|
|
224e606439 | ||
|
|
14b3344a41 | ||
|
|
a538ba64d2 | ||
|
|
b3bfad7fea | ||
|
|
f80b45e36a | ||
|
|
f3d60b141f | ||
|
|
5463dc261c |
@@ -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
|
||||||
|
|||||||
@@ -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,7 @@ 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
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
apps/api/v2/dist
|
|
||||||
packages/platform/**/dist/*
|
|
||||||
**/**/node_modules
|
|
||||||
**/**/.next
|
|
||||||
**/**/public
|
|
||||||
packages/prisma/zod
|
|
||||||
apps/web/public/embed
|
|
||||||
packages/ui/components/icon/dynamicIconImports.tsx
|
|
||||||
-115
@@ -1,115 +0,0 @@
|
|||||||
/** @type {import("eslint").Linter.Config} */
|
|
||||||
module.exports = {
|
|
||||||
extends: ["./packages/config/eslint-preset.js"],
|
|
||||||
plugins: ["import"],
|
|
||||||
rules: {
|
|
||||||
"import/no-cycle": ["warn", { maxDepth: Infinity }],
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
// WARN: features must not be imported by app-store or lib
|
|
||||||
{
|
|
||||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: [
|
|
||||||
// Catch all relative paths into features
|
|
||||||
"**/features",
|
|
||||||
"**/features/*",
|
|
||||||
// Catch all alias imports
|
|
||||||
"@calcom/features",
|
|
||||||
"@calcom/features/*",
|
|
||||||
],
|
|
||||||
message: "Avoid importing @calcom/features from @calcom/app-store or @calcom/lib.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// WARN: lib must not import app-store or features
|
|
||||||
{
|
|
||||||
files: ["packages/lib/**/*.{ts,tsx,js,jsx}"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: [
|
|
||||||
// Catch all relative paths into app-store
|
|
||||||
"**/app-store",
|
|
||||||
"**/app-store/*",
|
|
||||||
// Catch all relative paths into features
|
|
||||||
"**/features",
|
|
||||||
"**/features/*",
|
|
||||||
// Catch alias imports
|
|
||||||
"@calcom/app-store",
|
|
||||||
"@calcom/app-store/*",
|
|
||||||
"@calcom/features",
|
|
||||||
"@calcom/features/*",
|
|
||||||
],
|
|
||||||
message: "@calcom/lib should not import @calcom/app-store or @calcom/features.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// ERROR: app-store must not import trpc
|
|
||||||
{
|
|
||||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: [
|
|
||||||
// Catch all relative paths into trpc
|
|
||||||
"**/trpc",
|
|
||||||
"**/trpc/*",
|
|
||||||
// Catch alias imports
|
|
||||||
"@calcom/trpc",
|
|
||||||
"@calcom/trpc/*",
|
|
||||||
"@trpc",
|
|
||||||
"@trpc/*",
|
|
||||||
],
|
|
||||||
message:
|
|
||||||
"@calcom/app-store must not import trpc. Move UI to apps/web/components/apps or introduce an API boundary.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ERROR: prisma must not import `features` package
|
|
||||||
{
|
|
||||||
files: ["packages/prisma/**/*.{ts,tsx,js,jsx}"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: [
|
|
||||||
// Catch all relative paths into features
|
|
||||||
"**/features",
|
|
||||||
"**/features/*",
|
|
||||||
// Catch all alias imports
|
|
||||||
"@calcom/features",
|
|
||||||
"@calcom/features/*",
|
|
||||||
],
|
|
||||||
message: "Avoid importing @calcom/features from @calcom/prisma.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
+6
-6
@@ -1,5 +1,5 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/API
|
||||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/lib/csp.ts @calcom/Foundation
|
/apps/web/lib/csp.ts @calcom/Foundation
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -74,14 +74,8 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
e2e-atoms:
|
|
||||||
name: Tests
|
|
||||||
needs: [lint, build, build-atoms, build-api-v2]
|
|
||||||
uses: ./.github/workflows/e2e-atoms.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: E2E
|
name: Check breaking changes and run E2E
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
env:
|
env:
|
||||||
@@ -23,6 +23,10 @@ env:
|
|||||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
|
||||||
|
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
|
||||||
|
JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
|
||||||
|
NODE_ENV: ${{ vars.CI_NODE_ENV }}
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -68,6 +72,18 @@ jobs:
|
|||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- uses: ./.github/actions/cache-db
|
- uses: ./.github/actions/cache-db
|
||||||
|
|
||||||
|
- name: Generate Swagger
|
||||||
|
working-directory: apps/api/v2
|
||||||
|
run: yarn generate-swagger
|
||||||
|
|
||||||
|
- name: Check breaking changes
|
||||||
|
uses: oasdiff/oasdiff-action/breaking@main
|
||||||
|
with:
|
||||||
|
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||||
|
revision: docs/api-reference/v2/openapi.json
|
||||||
|
fail-on: ERR
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
run: |
|
run: |
|
||||||
@@ -75,6 +91,7 @@ jobs:
|
|||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier"
|
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
|
||||||
apply-labels-from-issue:
|
apply-labels-from-issue:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|||||||
@@ -195,13 +195,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
e2e-atoms:
|
|
||||||
name: Tests
|
|
||||||
needs: [changes, check-label, build, build-atoms, build-api-v2]
|
|
||||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
|
||||||
uses: ./.github/workflows/e2e-atoms.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze Build
|
name: Analyze Build
|
||||||
needs: [build]
|
needs: [build]
|
||||||
@@ -211,7 +204,7 @@ jobs:
|
|||||||
merge-reports:
|
merge-reports:
|
||||||
name: Merge reports
|
name: Merge reports
|
||||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
uses: ./.github/workflows/merge-reports.yml
|
uses: ./.github/workflows/merge-reports.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
@@ -255,8 +248,7 @@ jobs:
|
|||||||
e2e-api-v2,
|
e2e-api-v2,
|
||||||
e2e-embed,
|
e2e-embed,
|
||||||
e2e-embed-react,
|
e2e-embed-react,
|
||||||
e2e-app-store,
|
e2e-app-store
|
||||||
e2e-atoms,
|
|
||||||
]
|
]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ public
|
|||||||
packages/prisma/zod
|
packages/prisma/zod
|
||||||
packages/prisma/enums
|
packages/prisma/enums
|
||||||
apps/web/public/embed
|
apps/web/public/embed
|
||||||
apps/api/v2/swagger/documentation.json
|
|
||||||
packages/ui/components/icon/dynamicIconImports.tsx
|
packages/ui/components/icon/dynamicIconImports.tsx
|
||||||
|
|||||||
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
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
@@ -13,5 +13,8 @@
|
|||||||
"connect": "^3.7.0",
|
"connect": "^3.7.0",
|
||||||
"http": "^0.0.1-security",
|
"http": "^0.0.1-security",
|
||||||
"http-proxy-middleware": "^2.0.6"
|
"http-proxy-middleware": "^2.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@calcom/eslint-config": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||||
|
|
||||||
|
export default nextJsConfig;
|
||||||
@@ -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: {
|
|
||||||
email: "admin@example.com",
|
|
||||||
role: UserPermissionRole.ADMIN,
|
role: UserPermissionRole.ADMIN,
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
email: "admin@example.com",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: {
|
|
||||||
email: "org-admin@acme.com",
|
|
||||||
role: UserPermissionRole.USER,
|
role: UserPermissionRole.USER,
|
||||||
locked: false,
|
locked: false,
|
||||||
teams: {
|
email: "org-admin@acme.com",
|
||||||
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: {
|
|
||||||
email: "locked@example.com",
|
|
||||||
role: UserPermissionRole.USER,
|
role: UserPermissionRole.USER,
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
email: "locked@example.com",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -10,7 +10,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||||
"dev": "PORT=3003 next dev",
|
"dev": "PORT=3003 next dev",
|
||||||
"lint": "eslint . --ignore-path .gitignore",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||||
"start": "PORT=3003 next start",
|
"start": "PORT=3003 next start",
|
||||||
"docker-start-api": "PORT=80 next start",
|
"docker-start-api": "PORT=80 next start",
|
||||||
@@ -18,18 +18,19 @@
|
|||||||
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@calcom/tsconfig": "*",
|
"@calcom/eslint-config": "workspace:*",
|
||||||
"@calcom/types": "*",
|
"@calcom/tsconfig": "workspace:*",
|
||||||
|
"@calcom/types": "workspace:*",
|
||||||
"node-mocks-http": "^1.11.0"
|
"node-mocks-http": "^1.11.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/app-store": "*",
|
"@calcom/app-store": "workspace:*",
|
||||||
"@calcom/dayjs": "*",
|
"@calcom/dayjs": "workspace:*",
|
||||||
"@calcom/emails": "*",
|
"@calcom/emails": "workspace:*",
|
||||||
"@calcom/features": "*",
|
"@calcom/features": "workspace:*",
|
||||||
"@calcom/lib": "*",
|
"@calcom/lib": "workspace:*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "workspace:*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "workspace:*",
|
||||||
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
|
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
|
||||||
"@sentry/nextjs": "^9.15.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
{
|
|
||||||
|
return await regularBookingService.createBookingForApiV1({
|
||||||
bookingData: req.body,
|
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";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { 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";
|
||||||
|
|||||||
@@ -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) {
|
||||||
if (slugAlreadyExists && data.slug !== _team.slug)
|
|
||||||
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
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";
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [total, data] = await prisma.$transaction([
|
const [total, data] = await Promise.all([
|
||||||
prisma.user.count({ where }),
|
prisma.user.count({ where }),
|
||||||
prisma.user.findMany({ where, take, skip }),
|
prisma.user.findMany({ where, take, skip }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 { 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(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 { buildBooking } from "@calcom/lib/test/builder";
|
||||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,73 +100,44 @@ 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 () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
mockCreate.mockResolvedValue({
|
||||||
method: "POST",
|
id: 1,
|
||||||
body: {
|
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
username: "test",
|
username: "testuser123",
|
||||||
},
|
|
||||||
prisma: prismock,
|
|
||||||
});
|
|
||||||
req.isSystemWideAdmin = true;
|
|
||||||
|
|
||||||
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({
|
|
||||||
email: "test@example.com",
|
|
||||||
username: "test",
|
|
||||||
locked: false,
|
locked: false,
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should auto lock user if email is in watchlist", async () => {
|
|
||||||
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;
|
||||||
|
|
||||||
await prismock.watchlist.create({
|
|
||||||
data: {
|
|
||||||
type: "EMAIL",
|
|
||||||
value: "test@example.com",
|
|
||||||
severity: "CRITICAL",
|
|
||||||
createdById: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
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: true,
|
locked: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.user).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: "test@example.com",
|
||||||
|
username: "testuser123",
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,52 @@
|
|||||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
import type { NextApiRequest } from "next";
|
||||||
|
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
import { describe, expect, it, beforeEach } from "vitest";
|
import { 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,31 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
parserOptions: {
|
|
||||||
project: "tsconfig.json",
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
plugins: ["@typescript-eslint/eslint-plugin"],
|
|
||||||
extends: ["plugin:@typescript-eslint/recommended"],
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
jest: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/interface-name-prefix": "off",
|
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ["./src/**/*.controller.ts"],
|
|
||||||
excludedFiles: "*.spec.js",
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/explicit-function-return-type": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
|
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
|
||||||
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
|
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
|
||||||
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
|
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
|
||||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
||||||
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
|
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
|
||||||
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
@@ -30,18 +30,19 @@
|
|||||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
|
||||||
|
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
|
||||||
"prepare": "yarn run snyk-protect",
|
"prepare": "yarn run snyk-protect",
|
||||||
"snyk-protect": "snyk-protect"
|
"snyk-protect": "snyk-protect"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "workspace:*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "workspace:*",
|
||||||
"@calcom/platform-libraries": "*",
|
"@calcom/platform-libraries": "workspace:*",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "workspace:*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "workspace:*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "workspace:*",
|
||||||
"@golevelup/ts-jest": "^0.4.0",
|
"@golevelup/ts-jest": "^0.4.0",
|
||||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@calcom/eslint-config": "workspace:*",
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
|||||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
import { EventTypesModule_2024_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 { 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 +38,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,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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 { 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";
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
|
|||||||
|
|
||||||
let eventTypeId: number;
|
let eventTypeId: number;
|
||||||
|
|
||||||
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
|
let createdBooking: RegularBookingCreateResult;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -131,8 +131,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 +230,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 +287,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 +384,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();
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
|
|||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { 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 { 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 { 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";
|
||||||
@@ -45,11 +48,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 +58,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 +110,10 @@ 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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@@ -187,8 +191,9 @@ export class BookingsController_2024_04_15 {
|
|||||||
const { orgSlug, locationUrl } = body;
|
const { orgSlug, locationUrl } = body;
|
||||||
try {
|
try {
|
||||||
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,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
forcedSlug: orgSlug,
|
forcedSlug: orgSlug,
|
||||||
@@ -198,6 +203,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
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, {
|
||||||
@@ -315,8 +321,9 @@ export class BookingsController_2024_04_15 {
|
|||||||
|
|
||||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||||
|
|
||||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
|
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
@@ -324,6 +331,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createdBookings.forEach(async (booking) => {
|
createdBookings.forEach(async (booking) => {
|
||||||
@@ -351,14 +359,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();
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_0
|
|||||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
import { 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 { 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 +61,9 @@ import { Module } from "@nestjs/common";
|
|||||||
TeamsEventTypesModule,
|
TeamsEventTypesModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
|
RegularBookingModule,
|
||||||
|
RecurringBookingModule,
|
||||||
|
InstantBookingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
|
|||||||
@@ -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,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
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.
|
||||||
|
|
||||||
|
<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 +190,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
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 +210,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 +228,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 +246,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 +276,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: {
|
||||||
@@ -315,6 +331,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
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({
|
||||||
@@ -350,7 +368,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 +393,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 +417,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 +448,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 +472,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 +496,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 +518,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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, ValidateNested } from "class-validator";
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(ReassignBookingOutputData_2024_08_13)
|
||||||
export class ReassignBookingOutput_2024_08_13 {
|
export class ReassignBookingOutput_2024_08_13 {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
BookingOutput_2024_08_13,
|
BookingOutput_2024_08_13,
|
||||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||||
CreateSeatedBookingOutput_2024_08_13,
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
|
||||||
GetSeatedBookingOutput_2024_08_13,
|
|
||||||
RecurringBookingOutput_2024_08_13,
|
RecurringBookingOutput_2024_08_13,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 @@ import {
|
|||||||
confirmBookingHandler,
|
confirmBookingHandler,
|
||||||
getCalendarLinks,
|
getCalendarLinks,
|
||||||
} from "@calcom/platform-libraries";
|
} from "@calcom/platform-libraries";
|
||||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
|
||||||
import {
|
import {
|
||||||
CreateBookingInput_2024_08_13,
|
CreateBookingInput_2024_08_13,
|
||||||
CreateBookingInput,
|
CreateBookingInput,
|
||||||
@@ -108,7 +109,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 +136,10 @@ export class BookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType.schedulingType === "COLLECTIVE" || eventType.schedulingType === "ROUND_ROBIN") {
|
||||||
|
await this.checkEventTypeHasHosts(eventType.id);
|
||||||
|
}
|
||||||
|
|
||||||
body.eventTypeId = eventType.id;
|
body.eventTypeId = eventType.id;
|
||||||
|
|
||||||
if ("instant" in body && body.instant) {
|
if ("instant" in body && body.instant) {
|
||||||
@@ -159,6 +167,15 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkEventTypeHasHosts(eventTypeId: number) {
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeWithHosts(eventTypeId);
|
||||||
|
if (!eventType?.hosts?.length) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${eventTypeId} belonging to team with id=${eventType?.teamId} and try again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async checkBookingRequiresAuthenticationSetting(
|
async checkBookingRequiresAuthenticationSetting(
|
||||||
eventType: EventTypeWithOwnerAndTeam,
|
eventType: EventTypeWithOwnerAndTeam,
|
||||||
authUser: AuthOptionalUser,
|
authUser: AuthOptionalUser,
|
||||||
@@ -450,7 +467,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,8 +487,9 @@ 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,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
@@ -479,6 +499,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
noEmail: bookingRequest.noEmail,
|
noEmail: bookingRequest.noEmail,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
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,8 +512,9 @@ 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,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
@@ -501,6 +523,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
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,8 +537,9 @@ 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,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
@@ -524,6 +548,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
@@ -546,8 +571,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);
|
||||||
try {
|
try {
|
||||||
const booking = await handleNewBooking({
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
@@ -556,6 +582,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
@@ -741,8 +768,9 @@ export class BookingsService_2024_08_13 {
|
|||||||
bookingUid,
|
bookingUid,
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
const booking = await handleNewBooking({
|
const booking = await this.regularBookingService.createBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
|
bookingMeta: {
|
||||||
userId: bookingRequest.userId,
|
userId: bookingRequest.userId,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformClientId: bookingRequest.platformClientId,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
@@ -751,6 +779,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
throw new Error("Booking missing uid");
|
throw new Error("Booking missing uid");
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 {
|
|||||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
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;
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
|
// 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,
|
||||||
};
|
};
|
||||||
|
|||||||
+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({
|
||||||
|
|||||||
+232
-17
@@ -31,15 +31,18 @@ import {
|
|||||||
FrequencyInput,
|
FrequencyInput,
|
||||||
} from "@calcom/platform-enums";
|
} from "@calcom/platform-enums";
|
||||||
import { SchedulingType } from "@calcom/platform-libraries";
|
import { SchedulingType } from "@calcom/platform-libraries";
|
||||||
import type {
|
import {
|
||||||
ApiSuccessResponse,
|
type ApiSuccessResponse,
|
||||||
CreateEventTypeInput_2024_06_14,
|
type CreateEventTypeInput_2024_06_14,
|
||||||
EventTypeOutput_2024_06_14,
|
type EventTypeOutput_2024_06_14,
|
||||||
GuestsDefaultFieldOutput_2024_06_14,
|
type GuestsDefaultFieldOutput_2024_06_14,
|
||||||
NameDefaultFieldInput_2024_06_14,
|
type NameDefaultFieldInput_2024_06_14,
|
||||||
NotesDefaultFieldInput_2024_06_14,
|
type NotesDefaultFieldInput_2024_06_14,
|
||||||
UpdateEventTypeInput_2024_06_14,
|
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||||
|
type UpdateEventTypeInput_2024_06_14,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||||
|
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
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 }) => {
|
||||||
@@ -1441,29 +1444,200 @@ 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();
|
||||||
});
|
});
|
||||||
@@ -2187,6 +2361,47 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("split name booking field", () => {
|
||||||
|
it("should create an event type with split name booking field", async () => {
|
||||||
|
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
|
||||||
|
isDefault: true,
|
||||||
|
type: "splitName",
|
||||||
|
slug: "splitName",
|
||||||
|
firstNameLabel: "First name",
|
||||||
|
firstNamePlaceholder: "",
|
||||||
|
lastNameLabel: "last name",
|
||||||
|
lastNamePlaceholder: "",
|
||||||
|
lastNameRequired: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Coding class 9",
|
||||||
|
slug: "coding-class-9",
|
||||||
|
description: "Let's learn how to code like a pro.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
bookingFields: [splitNameBookingField],
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
|
const createdEventType = responseBody.data;
|
||||||
|
|
||||||
|
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
|
||||||
|
(field) => field.type === "splitName"
|
||||||
|
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
|
||||||
|
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
|
||||||
|
|
||||||
|
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
@@ -2194,12 +2409,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 +2594,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,12 +2604,12 @@ 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
|
||||||
|
|||||||
+65
-25
@@ -28,7 +28,11 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
|||||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||||
import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
|
import {
|
||||||
|
validateCustomEventName,
|
||||||
|
EventTypeMetaDataSchema,
|
||||||
|
EventTypeMetadata,
|
||||||
|
} from "@calcom/platform-libraries/event-types";
|
||||||
import {
|
import {
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
DestinationCalendar_2024_06_14,
|
DestinationCalendar_2024_06_14,
|
||||||
@@ -67,11 +71,13 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
eventName: transformedBody.eventName,
|
eventName: transformedBody.eventName,
|
||||||
});
|
});
|
||||||
|
|
||||||
transformedBody.destinationCalendar &&
|
if (transformedBody.destinationCalendar) {
|
||||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
|
||||||
|
}
|
||||||
|
|
||||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
await this.validateInputUseDestinationCalendarEmail(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
return transformedBody;
|
return transformedBody;
|
||||||
}
|
}
|
||||||
@@ -93,11 +99,13 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
eventName: transformedBody.eventName,
|
eventName: transformedBody.eventName,
|
||||||
});
|
});
|
||||||
|
|
||||||
transformedBody.destinationCalendar &&
|
if (transformedBody.destinationCalendar) {
|
||||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
|
||||||
|
}
|
||||||
|
|
||||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
await this.validateInputUseDestinationCalendarEmail(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
return transformedBody;
|
return transformedBody;
|
||||||
}
|
}
|
||||||
@@ -119,6 +127,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
customName,
|
customName,
|
||||||
useDestinationCalendarEmail,
|
useDestinationCalendarEmail,
|
||||||
disableGuests,
|
disableGuests,
|
||||||
|
bookerActiveBookingsLimit,
|
||||||
...rest
|
...rest
|
||||||
} = inputEventType;
|
} = inputEventType;
|
||||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||||
@@ -130,6 +139,17 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||||
: bookingFields;
|
: bookingFields;
|
||||||
|
|
||||||
|
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
|
||||||
|
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const metadata: EventTypeMetadata = {
|
||||||
|
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||||
|
requiresConfirmationThreshold:
|
||||||
|
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||||
|
multipleDuration: lengthInMinutesOptions,
|
||||||
|
};
|
||||||
|
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...rest,
|
...rest,
|
||||||
length: lengthInMinutes,
|
length: lengthInMinutes,
|
||||||
@@ -140,12 +160,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||||
: undefined,
|
: undefined,
|
||||||
...this.transformInputBookingWindow(bookingWindow),
|
...this.transformInputBookingWindow(bookingWindow),
|
||||||
metadata: {
|
metadata,
|
||||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
|
||||||
requiresConfirmationThreshold:
|
|
||||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
|
||||||
multipleDuration: lengthInMinutesOptions,
|
|
||||||
},
|
|
||||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||||
requiresConfirmationWillBlockSlot:
|
requiresConfirmationWillBlockSlot:
|
||||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||||
@@ -154,11 +169,27 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
...this.transformInputSeatOptions(seats),
|
...this.transformInputSeatOptions(seats),
|
||||||
eventName: customName,
|
eventName: customName,
|
||||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||||
|
...maxActiveBookingsPerBooker,
|
||||||
};
|
};
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transformInputBookerActiveBookingsLimit(
|
||||||
|
bookerActiveBookingsLimit: CreateEventTypeInput_2024_06_14["bookerActiveBookingsLimit"]
|
||||||
|
) {
|
||||||
|
if (!bookerActiveBookingsLimit || bookerActiveBookingsLimit.disabled) {
|
||||||
|
return {
|
||||||
|
maxActiveBookingsPerBooker: null,
|
||||||
|
maxActiveBookingPerBookerOfferReschedule: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxActiveBookingsPerBooker: bookerActiveBookingsLimit?.maximumActiveBookings,
|
||||||
|
maxActiveBookingPerBookerOfferReschedule: bookerActiveBookingsLimit?.offerReschedule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
|
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
|
||||||
const {
|
const {
|
||||||
lengthInMinutes,
|
lengthInMinutes,
|
||||||
@@ -176,10 +207,11 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
customName,
|
customName,
|
||||||
useDestinationCalendarEmail,
|
useDestinationCalendarEmail,
|
||||||
disableGuests,
|
disableGuests,
|
||||||
|
bookerActiveBookingsLimit,
|
||||||
...rest
|
...rest
|
||||||
} = inputEventType;
|
} = inputEventType;
|
||||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||||
const metadataTransformed = !!eventTypeDb?.metadata
|
const metadataTransformed = eventTypeDb?.metadata
|
||||||
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
|
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
@@ -190,6 +222,18 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||||
: bookingFields;
|
: bookingFields;
|
||||||
|
|
||||||
|
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
|
||||||
|
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const metadata: EventTypeMetadata = {
|
||||||
|
...metadataTransformed,
|
||||||
|
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||||
|
requiresConfirmationThreshold:
|
||||||
|
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||||
|
multipleDuration: lengthInMinutesOptions,
|
||||||
|
};
|
||||||
|
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...rest,
|
...rest,
|
||||||
length: lengthInMinutes,
|
length: lengthInMinutes,
|
||||||
@@ -202,13 +246,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||||
: undefined,
|
: undefined,
|
||||||
...this.transformInputBookingWindow(bookingWindow),
|
...this.transformInputBookingWindow(bookingWindow),
|
||||||
metadata: {
|
metadata,
|
||||||
...metadataTransformed,
|
|
||||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
|
||||||
requiresConfirmationThreshold:
|
|
||||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
|
||||||
multipleDuration: lengthInMinutesOptions,
|
|
||||||
},
|
|
||||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||||
requiresConfirmationWillBlockSlot:
|
requiresConfirmationWillBlockSlot:
|
||||||
@@ -217,6 +255,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
...this.transformInputSeatOptions(seats),
|
...this.transformInputSeatOptions(seats),
|
||||||
eventName: customName,
|
eventName: customName,
|
||||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||||
|
...maxActiveBookingsPerBooker,
|
||||||
};
|
};
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
@@ -319,7 +358,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
|
|
||||||
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
|
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
|
||||||
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
|
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
|
||||||
return !!res ? res : {};
|
return res ? res : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
|
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
|
||||||
@@ -369,7 +408,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
|
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seatsPerTimeSlotFinal = !!seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
const seatsPerTimeSlotFinal = seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
||||||
const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0;
|
const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0;
|
||||||
|
|
||||||
const locationsFinal = locations !== undefined ? locations : locationsDb;
|
const locationsFinal = locations !== undefined ? locations : locationsDb;
|
||||||
@@ -394,6 +433,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformLocations(locations: any) {
|
transformLocations(locations: any) {
|
||||||
if (!locations) return [];
|
if (!locations) return [];
|
||||||
|
|
||||||
|
|||||||
+28
@@ -92,6 +92,8 @@ type Input = Pick<
|
|||||||
| "calVideoSettings"
|
| "calVideoSettings"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
| "bookingRequiresAuthentication"
|
| "bookingRequiresAuthentication"
|
||||||
|
| "maxActiveBookingsPerBooker"
|
||||||
|
| "maxActiveBookingPerBookerOfferReschedule"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -165,6 +167,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
periodEndDate: databaseEventType.periodEndDate,
|
periodEndDate: databaseEventType.periodEndDate,
|
||||||
} as TransformFutureBookingsLimitSchema_2024_06_14);
|
} as TransformFutureBookingsLimitSchema_2024_06_14);
|
||||||
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
|
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
|
||||||
|
const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -210,9 +213,29 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
calVideoSettings,
|
calVideoSettings,
|
||||||
hidden,
|
hidden,
|
||||||
bookingRequiresAuthentication,
|
bookingRequiresAuthentication,
|
||||||
|
bookerActiveBookingsLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transformBookerActiveBookingsLimit(databaseEventType: Input) {
|
||||||
|
const noMaxActiveBookingsPerBooker =
|
||||||
|
!databaseEventType.maxActiveBookingsPerBooker && databaseEventType.maxActiveBookingsPerBooker !== 0;
|
||||||
|
const noMaxActiveBookingPerBookerOfferReschedule =
|
||||||
|
!databaseEventType.maxActiveBookingPerBookerOfferReschedule;
|
||||||
|
|
||||||
|
if (noMaxActiveBookingsPerBooker && noMaxActiveBookingPerBookerOfferReschedule) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
maximumActiveBookings: databaseEventType.maxActiveBookingsPerBooker ?? undefined,
|
||||||
|
offerReschedule: databaseEventType.maxActiveBookingPerBookerOfferReschedule ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformLocations(locationDb: any) {
|
transformLocations(locationDb: any) {
|
||||||
if (!locationDb) return [];
|
if (!locationDb) return [];
|
||||||
|
|
||||||
@@ -239,6 +262,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformBookingFields(bookingFields: any) {
|
transformBookingFields(bookingFields: any) {
|
||||||
if (!bookingFields) return [];
|
if (!bookingFields) return [];
|
||||||
|
|
||||||
@@ -273,6 +297,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
return this.transformBookingFields(defaultBookingFields);
|
return this.transformBookingFields(defaultBookingFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformRecurringEvent(recurringEvent: any) {
|
transformRecurringEvent(recurringEvent: any) {
|
||||||
if (!recurringEvent) return null;
|
if (!recurringEvent) return null;
|
||||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||||
@@ -280,6 +305,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
return transformRecurrenceInternalToApi(recurringEventParsed);
|
return transformRecurrenceInternalToApi(recurringEventParsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformMetadata(metadata: any) {
|
transformMetadata(metadata: any) {
|
||||||
if (!metadata) return {};
|
if (!metadata) return {};
|
||||||
return EventTypeMetaDataSchema.parse(metadata);
|
return EventTypeMetaDataSchema.parse(metadata);
|
||||||
@@ -301,6 +327,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformIntervalLimits(bookingLimits: any) {
|
transformIntervalLimits(bookingLimits: any) {
|
||||||
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
|
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
|
||||||
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
|
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
|
||||||
@@ -327,6 +354,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
transformEventTypeColor(eventTypeColor: any) {
|
transformEventTypeColor(eventTypeColor: any) {
|
||||||
if (!eventTypeColor) return undefined;
|
if (!eventTypeColor) return undefined;
|
||||||
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
|
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
|
||||||
|
|||||||
+2
@@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit<
|
|||||||
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
|
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
|
||||||
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
|
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
|
||||||
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;
|
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;
|
||||||
|
maxActiveBookingsPerBooker?: number;
|
||||||
|
maxActiveBookingPerBookerOfferReschedule?: boolean;
|
||||||
eventTypeColor?: ReturnType<typeof transformEventColorsApiToInternal>;
|
eventTypeColor?: ReturnType<typeof transformEventColorsApiToInternal>;
|
||||||
useEventTypeDestinationCalendarEmail?: boolean;
|
useEventTypeDestinationCalendarEmail?: boolean;
|
||||||
} & Partial<
|
} & Partial<
|
||||||
|
|||||||
+1
-1
@@ -556,7 +556,7 @@ export const systemBeforeFieldEmail: EmailSystemField = {
|
|||||||
type: "email",
|
type: "email",
|
||||||
name: "email",
|
name: "email",
|
||||||
required: true,
|
required: true,
|
||||||
editable: "system",
|
editable: "system-but-optional",
|
||||||
sources: [
|
sources: [
|
||||||
{
|
{
|
||||||
label: "Default",
|
label: "Default",
|
||||||
|
|||||||
+23
-6
@@ -40,7 +40,7 @@ import {
|
|||||||
@DocsTags("Schedules")
|
@DocsTags("Schedules")
|
||||||
@ApiHeader({
|
@ApiHeader({
|
||||||
name: "cal-api-version",
|
name: "cal-api-version",
|
||||||
description: `Must be set to ${VERSION_2024_06_11}`,
|
description: `Must be set to ${VERSION_2024_06_11}. If not set to this value, the endpoint will default to an older version.`,
|
||||||
example: VERSION_2024_06_11,
|
example: VERSION_2024_06_11,
|
||||||
required: true,
|
required: true,
|
||||||
schema: {
|
schema: {
|
||||||
@@ -69,6 +69,8 @@ export class SchedulesController_2024_06_11 {
|
|||||||
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
|
||||||
|
|
||||||
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
|
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
|
||||||
|
|
||||||
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
async createSchedule(
|
async createSchedule(
|
||||||
@@ -91,7 +93,10 @@ export class SchedulesController_2024_06_11 {
|
|||||||
})
|
})
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get default schedule",
|
summary: "Get default schedule",
|
||||||
description: "Get the default schedule of the authenticated user.",
|
description: `Get the default schedule of the authenticated user.
|
||||||
|
|
||||||
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
|
`,
|
||||||
})
|
})
|
||||||
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
|
||||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||||
@@ -104,7 +109,10 @@ export class SchedulesController_2024_06_11 {
|
|||||||
|
|
||||||
@Get("/:scheduleId")
|
@Get("/:scheduleId")
|
||||||
@Permissions([SCHEDULE_READ])
|
@Permissions([SCHEDULE_READ])
|
||||||
@ApiOperation({ summary: "Get a schedule" })
|
@ApiOperation({
|
||||||
|
summary: "Get a schedule",
|
||||||
|
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||||
|
})
|
||||||
async getSchedule(
|
async getSchedule(
|
||||||
@GetUser() user: UserWithProfile,
|
@GetUser() user: UserWithProfile,
|
||||||
@Param("scheduleId") scheduleId: number
|
@Param("scheduleId") scheduleId: number
|
||||||
@@ -121,7 +129,10 @@ export class SchedulesController_2024_06_11 {
|
|||||||
@Permissions([SCHEDULE_READ])
|
@Permissions([SCHEDULE_READ])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all schedules",
|
summary: "Get all schedules",
|
||||||
description: "Get all schedules of the authenticated user.",
|
description: `Get all schedules of the authenticated user.
|
||||||
|
|
||||||
|
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||||
|
`,
|
||||||
})
|
})
|
||||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
|
||||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||||
@@ -134,7 +145,10 @@ export class SchedulesController_2024_06_11 {
|
|||||||
|
|
||||||
@Patch("/:scheduleId")
|
@Patch("/:scheduleId")
|
||||||
@Permissions([SCHEDULE_WRITE])
|
@Permissions([SCHEDULE_WRITE])
|
||||||
@ApiOperation({ summary: "Update a schedule" })
|
@ApiOperation({
|
||||||
|
summary: "Update a schedule",
|
||||||
|
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||||
|
})
|
||||||
async updateSchedule(
|
async updateSchedule(
|
||||||
@GetUser() user: UserWithProfile,
|
@GetUser() user: UserWithProfile,
|
||||||
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
|
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
|
||||||
@@ -155,7 +169,10 @@ export class SchedulesController_2024_06_11 {
|
|||||||
@Delete("/:scheduleId")
|
@Delete("/:scheduleId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Permissions([SCHEDULE_WRITE])
|
@Permissions([SCHEDULE_WRITE])
|
||||||
@ApiOperation({ summary: "Delete a schedule" })
|
@ApiOperation({
|
||||||
|
summary: "Delete a schedule",
|
||||||
|
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
|
||||||
|
})
|
||||||
async deleteSchedule(
|
async deleteSchedule(
|
||||||
@GetUser("id") userId: number,
|
@GetUser("id") userId: number,
|
||||||
@Param("scheduleId") scheduleId: number
|
@Param("scheduleId") scheduleId: number
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [InstantBookingCreateService],
|
||||||
|
exports: [InstantBookingCreateService],
|
||||||
|
})
|
||||||
|
export class InstantBookingModule {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||||
|
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [RegularBookingModule],
|
||||||
|
providers: [RecurringBookingService],
|
||||||
|
exports: [RecurringBookingService],
|
||||||
|
})
|
||||||
|
export class RecurringBookingModule {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
|
||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
|
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
|
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||||
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||||
|
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [
|
||||||
|
PrismaAttributeRepository,
|
||||||
|
PrismaBookingRepository,
|
||||||
|
PrismaFeaturesRepository,
|
||||||
|
PrismaHostRepository,
|
||||||
|
PrismaOOORepository,
|
||||||
|
PrismaUserRepository,
|
||||||
|
CacheService,
|
||||||
|
CheckBookingAndDurationLimitsService,
|
||||||
|
CheckBookingLimitsService,
|
||||||
|
LuckyUserService,
|
||||||
|
RegularBookingService,
|
||||||
|
],
|
||||||
|
exports: [RegularBookingService],
|
||||||
|
})
|
||||||
|
export class RegularBookingModule {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { InstantBookingCreateService as BaseInstantBookingCreateService } from "@calcom/platform-libraries/bookings";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstantBookingCreateService extends BaseInstantBookingCreateService {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecurringBookingService extends BaseRecurringBookingService {
|
||||||
|
constructor(regularBookingService: RegularBookingService) {
|
||||||
|
super({
|
||||||
|
regularBookingService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
|
||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
|
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
|
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
|
||||||
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
|
||||||
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RegularBookingService extends BaseRegularBookingService {
|
||||||
|
constructor(
|
||||||
|
cacheService: CacheService,
|
||||||
|
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
|
||||||
|
prismaWriteService: PrismaWriteService,
|
||||||
|
bookingRepository: PrismaBookingRepository,
|
||||||
|
featuresRepository: PrismaFeaturesRepository,
|
||||||
|
checkBookingLimitsService: CheckBookingLimitsService,
|
||||||
|
luckyUserService: LuckyUserService,
|
||||||
|
hostRepository: PrismaHostRepository,
|
||||||
|
oooRepository: PrismaOOORepository,
|
||||||
|
userRepository: PrismaUserRepository,
|
||||||
|
attributeRepository: PrismaAttributeRepository
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
cacheService,
|
||||||
|
checkBookingAndDurationLimitsService,
|
||||||
|
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
|
||||||
|
bookingRepository,
|
||||||
|
featuresRepository,
|
||||||
|
checkBookingLimitsService,
|
||||||
|
luckyUserService,
|
||||||
|
hostRepository,
|
||||||
|
oooRepository,
|
||||||
|
userRepository,
|
||||||
|
attributeRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-109
@@ -1,39 +1,29 @@
|
|||||||
import type { AppConfig } from "@/config/type";
|
import type { AppConfig } from "@/config/type";
|
||||||
import { getEnv } from "@/env";
|
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
|
||||||
import {
|
|
||||||
PathItemObject,
|
|
||||||
PathsObject,
|
|
||||||
OperationObject,
|
|
||||||
TagObject,
|
|
||||||
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import * as fs from "fs";
|
|
||||||
import { Server } from "http";
|
|
||||||
import { WinstonModule } from "nest-winston";
|
import { WinstonModule } from "nest-winston";
|
||||||
|
|
||||||
import { bootstrap } from "./app";
|
import { bootstrap } from "./app";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { loggerConfig } from "./lib/logger";
|
import { loggerConfig } from "./lib/logger";
|
||||||
|
import { generateSwaggerForApp } from "./swagger/generate-swagger";
|
||||||
|
|
||||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
run().catch((error: Error) => {
|
||||||
|
console.error("Failed to start Cal Platform API", { error: error.stack });
|
||||||
const run = async () => {
|
process.exit(1);
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
});
|
||||||
logger: WinstonModule.createLogger(loggerConfig()),
|
|
||||||
bodyParser: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const app = await createNestApp();
|
||||||
const logger = new Logger("App");
|
const logger = new Logger("App");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bootstrap(app);
|
bootstrap(app);
|
||||||
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
||||||
void generateSwagger(app);
|
generateSwaggerForApp(app);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
logger.log(`Application started on port: ${port}`);
|
logger.log(`Application started on port: ${port}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -42,97 +32,11 @@ const run = async () => {
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
function customTagSort(a: string, b: string): number {
|
|
||||||
const platformPrefix = "Platform";
|
|
||||||
const orgsPrefix = "Orgs";
|
|
||||||
|
|
||||||
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.localeCompare(b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOperationObject(obj: any): obj is OperationObject {
|
export async function createNestApp() {
|
||||||
return obj && typeof obj === "object" && "tags" in obj;
|
return NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
logger: WinstonModule.createLogger(loggerConfig()),
|
||||||
|
bodyParser: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
|
|
||||||
const groupedPaths: { [key: string]: PathsObject } = {};
|
|
||||||
|
|
||||||
Object.keys(paths).forEach((pathKey) => {
|
|
||||||
const pathItem = paths[pathKey];
|
|
||||||
|
|
||||||
HttpMethods.forEach((method) => {
|
|
||||||
const operation = pathItem[method];
|
|
||||||
|
|
||||||
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
|
|
||||||
const firstTag = operation.tags[0];
|
|
||||||
|
|
||||||
if (!groupedPaths[firstTag]) {
|
|
||||||
groupedPaths[firstTag] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
groupedPaths[firstTag][pathKey] = pathItem;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
|
|
||||||
const sortedPaths: PathsObject = {};
|
|
||||||
|
|
||||||
sortedTags.forEach((tag) => {
|
|
||||||
Object.assign(sortedPaths, groupedPaths[tag]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortedPaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateSwagger(app: NestExpressApplication<Server>) {
|
|
||||||
const logger = new Logger("App");
|
|
||||||
logger.log(`Generating Swagger documentation...\n`);
|
|
||||||
|
|
||||||
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
|
||||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
|
||||||
|
|
||||||
const swaggerOutputFile = "./swagger/documentation.json";
|
|
||||||
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
|
|
||||||
const stringifiedContents = JSON.stringify(document, null, 2);
|
|
||||||
|
|
||||||
if (fs.existsSync(swaggerOutputFile)) {
|
|
||||||
fs.unlinkSync(swaggerOutputFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
|
|
||||||
|
|
||||||
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
|
|
||||||
fs.unlinkSync(docsOutputFile);
|
|
||||||
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.DOCS_URL) {
|
|
||||||
SwaggerModule.setup("docs", app, document, {
|
|
||||||
customCss: ".swagger-ui .topbar { display: none }",
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error: Error) => {
|
|
||||||
console.error("Failed to start Cal Platform API", { error: error.stack });
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AtomsSecondaryEmailsRepository {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
|
async getSecondaryEmailsVerified(userId: number) {
|
||||||
|
return await this.dbRead.prisma.secondaryEmail.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
emailVerified: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
||||||
|
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_email: { userId, email },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return existingSecondaryEmailRecord?.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExistingSecondaryEmail(email: string) {
|
||||||
|
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return existingSecondaryEmailRecord?.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSecondaryEmailVerified(userId: number, email: string) {
|
||||||
|
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return existingSecondaryEmailRecord?.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
|
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
|
||||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||||
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
|
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
|
||||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||||
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
|
|||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
AtomsRepository,
|
AtomsRepository,
|
||||||
|
AtomsSecondaryEmailsRepository,
|
||||||
UsersService,
|
UsersService,
|
||||||
SchedulesAtomsService,
|
SchedulesAtomsService,
|
||||||
VerificationAtomsService,
|
VerificationAtomsService,
|
||||||
|
|||||||
@@ -64,47 +64,4 @@ export class AtomsRepository {
|
|||||||
|
|
||||||
return userTeams;
|
return userTeams;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSecondaryEmails(userId: number) {
|
|
||||||
return await this.dbRead.prisma.secondaryEmail.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
emailVerified: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
|
||||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_email: { userId, email },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return existingSecondaryEmailRecord?.email;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getExistingSecondaryEmail(email: string) {
|
|
||||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
|
||||||
where: {
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return existingSecondaryEmailRecord?.email;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addSecondaryEmail(userId: number, email: string) {
|
|
||||||
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
email,
|
|
||||||
emailVerified: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return existingSecondaryEmailRecord?.email;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Version,
|
Version,
|
||||||
@@ -21,8 +22,16 @@ import {
|
|||||||
ApiExcludeController as DocsExcludeController,
|
ApiExcludeController as DocsExcludeController,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import type {
|
||||||
|
GetAvailabilityListHandlerReturn,
|
||||||
|
DuplicateScheduleHandlerReturn,
|
||||||
|
} from "@calcom/platform-libraries/schedules";
|
||||||
|
import { getAvailabilityListHandler, duplicateScheduleHandler } from "@calcom/platform-libraries/schedules";
|
||||||
|
import type { CreateScheduleHandlerReturn, CreateScheduleSchema } from "@calcom/platform-libraries/schedules";
|
||||||
|
import { createScheduleHandler } from "@calcom/platform-libraries/schedules";
|
||||||
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
|
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
|
||||||
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
|
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
|
||||||
|
|
||||||
@@ -63,6 +72,22 @@ export class AtomsSchedulesController {
|
|||||||
data: schedule,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/schedules/all")
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@Permissions([SCHEDULE_READ])
|
||||||
|
async getAllUserSchedules(
|
||||||
|
@GetUser() user: UserWithProfile
|
||||||
|
): Promise<ApiResponse<Awaited<GetAvailabilityListHandlerReturn>>> {
|
||||||
|
const userSchedules = await getAvailabilityListHandler({ ctx: { user } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: userSchedules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("schedules/:scheduleId")
|
@Patch("schedules/:scheduleId")
|
||||||
@Permissions([SCHEDULE_WRITE])
|
@Permissions([SCHEDULE_WRITE])
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@@ -83,4 +108,36 @@ export class AtomsSchedulesController {
|
|||||||
data: updatedSchedule,
|
data: updatedSchedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("schedules/create")
|
||||||
|
@Permissions([SCHEDULE_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiOperation({ summary: "Create atom schedule" })
|
||||||
|
async createSchedule(
|
||||||
|
@GetUser() user: UserWithProfile,
|
||||||
|
@Body() bodySchedule: z.infer<typeof CreateScheduleSchema>
|
||||||
|
): Promise<ApiResponse<CreateScheduleHandlerReturn>> {
|
||||||
|
const createdSchedule = await createScheduleHandler({ input: bodySchedule, ctx: { user } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: createdSchedule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:scheduleId/duplicate")
|
||||||
|
@Permissions([SCHEDULE_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiOperation({ summary: "Duplicate existing schedule" })
|
||||||
|
async duplicateExistingSchedule(
|
||||||
|
@GetUser() user: UserWithProfile,
|
||||||
|
@Param("scheduleId", ParseIntPipe) scheduleId: number
|
||||||
|
): Promise<ApiResponse<Awaited<DuplicateScheduleHandlerReturn>>> {
|
||||||
|
const duplicatedSchedule = await duplicateScheduleHandler({ ctx: { user }, input: { scheduleId } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: duplicatedSchedule,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
|
||||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||||
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
|
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
|
||||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class VerificationAtomsService {
|
export class VerificationAtomsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly atomsRepository: AtomsRepository,
|
private readonly atomsSecondaryEmailsRepository: AtomsSecondaryEmailsRepository,
|
||||||
private readonly teamsRepository: TeamsRepository
|
private readonly usersRepository: UsersRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
||||||
@@ -76,7 +76,8 @@ export class VerificationAtomsService {
|
|||||||
|
|
||||||
if (teamId) {
|
if (teamId) {
|
||||||
const verifiedEmails: string[] = [];
|
const verifiedEmails: string[] = [];
|
||||||
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
|
|
||||||
|
const teamMembers = await this.usersRepository.getUserEmailsVerifiedForTeam(teamId);
|
||||||
|
|
||||||
if (teamMembers.length === 0) {
|
if (teamMembers.length === 0) {
|
||||||
return verifiedEmails;
|
return verifiedEmails;
|
||||||
@@ -96,7 +97,7 @@ export class VerificationAtomsService {
|
|||||||
|
|
||||||
let verifiedEmails = [userEmailWithoutOauthClientId];
|
let verifiedEmails = [userEmailWithoutOauthClientId];
|
||||||
|
|
||||||
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
|
const secondaryEmails = await this.atomsSecondaryEmailsRepository.getSecondaryEmailsVerified(userId);
|
||||||
verifiedEmails = verifiedEmails.concat(
|
verifiedEmails = verifiedEmails.concat(
|
||||||
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
|
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
|
||||||
);
|
);
|
||||||
@@ -113,11 +114,9 @@ export class VerificationAtomsService {
|
|||||||
existingPrimaryEmail: string;
|
existingPrimaryEmail: string;
|
||||||
email: string;
|
email: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
|
const existingSecondaryEmail =
|
||||||
userId,
|
await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmailByUserAndEmail(userId, email);
|
||||||
email
|
const alreadyExistingEmail = await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmail(email);
|
||||||
);
|
|
||||||
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
|
|
||||||
|
|
||||||
if (alreadyExistingEmail) {
|
if (alreadyExistingEmail) {
|
||||||
throw new BadRequestException("Email already exists");
|
throw new BadRequestException("Email already exists");
|
||||||
@@ -127,7 +126,7 @@ export class VerificationAtomsService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.atomsRepository.addSecondaryEmail(userId, email);
|
await this.atomsSecondaryEmailsRepository.addSecondaryEmailVerified(userId, email);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
|||||||
import {
|
import {
|
||||||
NO_AUTH_PROVIDED_MESSAGE,
|
NO_AUTH_PROVIDED_MESSAGE,
|
||||||
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||||
|
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||||
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
|
||||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||||
@@ -9,11 +10,16 @@ export class OptionalApiAuthGuard extends ApiAuthGuard {
|
|||||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||||
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||||
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
|
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
|
||||||
|
const onlyClientSecretProvided = error && error.message.includes(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
|
||||||
|
|
||||||
if (onlyClientIdProvided) {
|
if (onlyClientIdProvided) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onlyClientSecretProvided) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (user || noAuthProvided || !error) {
|
if (user || noAuthProvided || !error) {
|
||||||
return user || null;
|
return user || null;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+6
-3
@@ -9,7 +9,7 @@ import {
|
|||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IsWorkflowInTeam implements CanActivate {
|
export class IsEventTypeWorkflowInTeam implements CanActivate {
|
||||||
constructor(private workflowsRepository: WorkflowsRepository) {}
|
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -41,10 +41,13 @@ export class IsWorkflowInTeam implements CanActivate {
|
|||||||
teamId: string,
|
teamId: string,
|
||||||
workflowId: string
|
workflowId: string
|
||||||
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||||
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
|
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(
|
||||||
|
Number(teamId),
|
||||||
|
Number(workflowId)
|
||||||
|
);
|
||||||
|
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
|
throw new NotFoundException(`IsWorkflowInTeam - event-type workflow (${workflowId}) not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workflow.teamId === Number(teamId)) {
|
if (workflow.teamId === Number(teamId)) {
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IsRoutingFormWorkflowInTeam implements CanActivate {
|
||||||
|
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request & { workflow?: WorkflowType }>();
|
||||||
|
const teamId: string = request.params.teamId;
|
||||||
|
const workflowId: string = request.params.workflowId;
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
throw new ForbiddenException("IsWorkflowInTeam - No workflow found in request params.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
throw new ForbiddenException("IsWorkflowInTeam - No team id found in request params.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { canAccess, workflow } = await this.checkIfWorkflowIsInTeam(teamId, workflowId);
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsTeamInOrg - Workflow with id=${workflowId} is not part of the team with id=${teamId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.workflow = workflow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIfWorkflowIsInTeam(
|
||||||
|
teamId: string,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||||
|
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(
|
||||||
|
Number(teamId),
|
||||||
|
Number(workflowId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workflow) {
|
||||||
|
throw new NotFoundException(`IsWorkflowInTeam - routing form workflow (${workflowId}) not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow.teamId === Number(teamId)) {
|
||||||
|
return { canAccess: true, workflow };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canAccess: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,11 @@ import { randomString } from "test/utils/randomString";
|
|||||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
|
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import { ApiAuthGuardRequest, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
|
import {
|
||||||
|
ApiAuthGuardRequest,
|
||||||
|
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||||
|
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||||
|
} from "./api-auth.strategy";
|
||||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||||
|
|
||||||
describe("ApiAuthStrategy", () => {
|
describe("ApiAuthStrategy", () => {
|
||||||
@@ -267,6 +271,29 @@ describe("ApiAuthStrategy", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw 401 if only OAuth Client Secret is provided", async () => {
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
headers: {
|
||||||
|
[X_CAL_SECRET_KEY]: `${oAuthClient.secret}gibberish`,
|
||||||
|
},
|
||||||
|
get: (key: string) => ({ origin: "http://localhost:3000" }[key]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.authenticate(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
expect(error.getStatus()).toEqual(401);
|
||||||
|
expect(error.message).toContain(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("should throw 401 if OAuth ID is invalid", async () => {
|
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||||
const context: ExecutionContext = {
|
const context: ExecutionContext = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export type ApiAuthGuardRequest = Request & {
|
|||||||
user: ApiAuthGuardUser;
|
user: ApiAuthGuardUser;
|
||||||
allowedAuthMethods?: AllowedAuthMethod[];
|
allowedAuthMethods?: AllowedAuthMethod[];
|
||||||
};
|
};
|
||||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
export const NO_AUTH_PROVIDED_MESSAGE = `No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as '${X_CAL_SECRET_KEY}' and '${X_CAL_CLIENT_ID}' headers`;
|
||||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
|
||||||
|
|
||||||
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE =
|
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE = `Only '${X_CAL_CLIENT_ID}' header provided. Please also provide '${X_CAL_SECRET_KEY}' header or Auth bearer token as 'Authentication' header`;
|
||||||
"Only 'x-cal-client-id' header provided. Please also provide 'x-cal-secret-key' header or Auth bearer token as 'Authentication' header";
|
|
||||||
|
export const ONLY_CLIENT_SECRET_PROVIDED_MESSAGE = `Only '${X_CAL_SECRET_KEY}' header provided. Please also provide '${X_CAL_CLIENT_ID}' header or Auth bearer token as 'Authentication' header`;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||||
@@ -121,6 +121,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
|
|
||||||
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
|
const onlyClientSecretProvided =
|
||||||
|
!oAuthClientId && !!oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
|
|
||||||
if (noAuthProvided) {
|
if (noAuthProvided) {
|
||||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||||
}
|
}
|
||||||
@@ -129,6 +132,10 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
|
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onlyClientSecretProvided) {
|
||||||
|
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_SECRET_PROVIDED_MESSAGE}`);
|
||||||
|
}
|
||||||
|
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
||||||
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
|
|||||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||||
|
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||||
@@ -9,6 +10,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { BullModule } from "@nestjs/bull";
|
import { BullModule } from "@nestjs/bull";
|
||||||
@@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
StripeModule,
|
StripeModule,
|
||||||
|
RedisModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
@@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
|
|||||||
providers: [
|
providers: [
|
||||||
BillingConfigService,
|
BillingConfigService,
|
||||||
BillingService,
|
BillingService,
|
||||||
|
{
|
||||||
|
provide: "IBillingService",
|
||||||
|
useClass: BillingServiceCachingProxy,
|
||||||
|
},
|
||||||
BillingRepository,
|
BillingRepository,
|
||||||
BillingProcessor,
|
BillingProcessor,
|
||||||
ManagedOrganizationsBillingService,
|
ManagedOrganizationsBillingService,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
@@ -16,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
|
|||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withNextAuth } from "test/utils/withNextAuth";
|
||||||
|
|
||||||
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withNextAuth(
|
||||||
userEmail,
|
userEmail,
|
||||||
Test.createTestingModule({
|
Test.createTestingModule({
|
||||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
@@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
organization = await organizationsRepositoryFixture.create({
|
organization = await organizationsRepositoryFixture.create({
|
||||||
name: `billing-organization-${randomString()}`,
|
name: `billing-organization-${randomString()}`,
|
||||||
|
isPlatform: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
@@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("/billing/webhook (GET) should not get billing plan for org since it's not set yet", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/billing/${organization.id}/check`)
|
||||||
|
|
||||||
|
.expect(200)
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = res.body.data as CheckPlatformBillingResponseDto;
|
||||||
|
expect(data?.plan).toEqual("FREE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("/billing/webhook (POST) should set billing free plan for org", () => {
|
it("/billing/webhook (POST) should set billing free plan for org", () => {
|
||||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
expect(billing?.plan).toEqual("FREE");
|
expect(billing?.plan).toEqual("FREE");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("/billing/webhook (GET) should get billing plan for org", () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/billing/${organization.id}/check`)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = res.body.data as CheckPlatformBillingResponseDto;
|
||||||
|
expect(data?.plan).toEqual("FREE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles
|
|||||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
Delete,
|
Delete,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
@@ -40,7 +41,7 @@ export class BillingController {
|
|||||||
private logger = new Logger("Billing Controller");
|
private logger = new Logger("Billing Controller");
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly billingService: BillingService,
|
@Inject("IBillingService") private readonly billingService: IBillingService,
|
||||||
public readonly stripeService: StripeService,
|
public readonly stripeService: StripeService,
|
||||||
private readonly configService: ConfigService<AppConfig>
|
private readonly configService: ConfigService<AppConfig>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { PlatformPlan } from "@/modules/billing/types";
|
||||||
|
import type { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { PlatformBilling, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
export type BillingData = {
|
||||||
|
team: (Team & { platformBilling: PlatformBilling | null }) | null;
|
||||||
|
status: "valid" | "no_subscription" | "no_billing";
|
||||||
|
plan: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IBillingService {
|
||||||
|
getBillingData(teamId: number): Promise<BillingData>;
|
||||||
|
createTeamBilling(teamId: number): Promise<string>;
|
||||||
|
redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise<string>;
|
||||||
|
updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string>;
|
||||||
|
cancelTeamSubscription(teamId: number): Promise<void>;
|
||||||
|
handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void>;
|
||||||
|
handleStripePaymentSuccess(event: Stripe.Event): Promise<void>;
|
||||||
|
handleStripePaymentFailed(event: Stripe.Event): Promise<void>;
|
||||||
|
handleStripePaymentPastDue(event: Stripe.Event): Promise<void>;
|
||||||
|
handleStripeCheckoutEvents(event: Stripe.Event): Promise<void>;
|
||||||
|
handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void>;
|
||||||
|
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null;
|
||||||
|
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null;
|
||||||
|
stripeService: StripeService;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { IBillingService, BillingData } from "@/modules/billing/interfaces/billing-service.interface";
|
||||||
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
|
import { PlatformPlan } from "@/modules/billing/types";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`;
|
||||||
|
export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingServiceCachingProxy implements IBillingService {
|
||||||
|
constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {}
|
||||||
|
|
||||||
|
async getBillingData(teamId: number) {
|
||||||
|
const cachedBillingData = await this.getBillingCache(teamId);
|
||||||
|
if (cachedBillingData) {
|
||||||
|
return cachedBillingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingData = await this.billingService.getBillingData(teamId);
|
||||||
|
await this.setBillingCache(teamId, billingData);
|
||||||
|
return billingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteBillingCache(teamId: number) {
|
||||||
|
await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBillingCache(teamId: number) {
|
||||||
|
const cachedResult = await this.redisService.get<BillingData>(REDIS_BILLING_CACHE_KEY(teamId));
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setBillingCache(teamId: number, billingData: BillingData): Promise<void> {
|
||||||
|
await this.redisService.set<BillingData>(REDIS_BILLING_CACHE_KEY(teamId), billingData, {
|
||||||
|
ttl: BILLING_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTeamBilling(teamId: number): Promise<string> {
|
||||||
|
return this.billingService.createTeamBilling(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async redirectToSubscribeCheckout(
|
||||||
|
teamId: number,
|
||||||
|
plan: PlatformPlan,
|
||||||
|
customerId?: string
|
||||||
|
): Promise<string> {
|
||||||
|
return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
|
||||||
|
return this.billingService.updateSubscriptionForTeam(teamId, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelTeamSubscription(teamId: number): Promise<void> {
|
||||||
|
await this.billingService.cancelTeamSubscription(teamId);
|
||||||
|
await this.deleteBillingCache(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
|
||||||
|
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const teamId = subscription?.metadata?.teamId;
|
||||||
|
if (teamId) {
|
||||||
|
await this.deleteBillingCache(Number.parseInt(teamId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
|
||||||
|
await this.billingService.handleStripePaymentSuccess(event);
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||||
|
if (subscriptionId) {
|
||||||
|
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
if (teamBilling?.id) {
|
||||||
|
await this.deleteBillingCache(teamBilling.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
|
||||||
|
await this.billingService.handleStripePaymentFailed(event);
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||||
|
if (subscriptionId) {
|
||||||
|
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
if (teamBilling?.id) {
|
||||||
|
await this.deleteBillingCache(teamBilling.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
|
||||||
|
await this.billingService.handleStripePaymentPastDue(event);
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const subscriptionId = subscription.id;
|
||||||
|
if (subscriptionId) {
|
||||||
|
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
if (teamBilling?.id) {
|
||||||
|
await this.deleteBillingCache(teamBilling.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
|
||||||
|
await this.billingService.handleStripeCheckoutEvents(event);
|
||||||
|
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||||
|
const teamId = checkoutSession.metadata?.teamId;
|
||||||
|
if (teamId) {
|
||||||
|
await this.deleteBillingCache(Number.parseInt(teamId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
|
||||||
|
return this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||||
|
return this.billingService.getSubscriptionIdFromInvoice(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||||
|
return this.billingService.getCustomerIdFromInvoice(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
get stripeService() {
|
||||||
|
return this.billingService.stripeService;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { AppConfig } from "@/config/type";
|
|||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
|
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||||
import { PlatformPlan } from "@/modules/billing/types";
|
import { PlatformPlan } from "@/modules/billing/types";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
@@ -23,14 +24,14 @@ import { DateTime } from "luxon";
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService implements OnModuleDestroy {
|
export class BillingService implements IBillingService, OnModuleDestroy {
|
||||||
private logger = new Logger("BillingService");
|
private logger = new Logger("BillingService");
|
||||||
private readonly webAppUrl: string;
|
private readonly webAppUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly teamsRepository: OrganizationsRepository,
|
private readonly teamsRepository: OrganizationsRepository,
|
||||||
public readonly stripeService: StripeService,
|
public readonly stripeService: StripeService,
|
||||||
private readonly billingRepository: BillingRepository,
|
public readonly billingRepository: BillingRepository,
|
||||||
private readonly configService: ConfigService<AppConfig>,
|
private readonly configService: ConfigService<AppConfig>,
|
||||||
private readonly billingConfigService: BillingConfigService,
|
private readonly billingConfigService: BillingConfigService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
@@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async getBillingData(teamId: number) {
|
async getBillingData(teamId: number) {
|
||||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
|
|
||||||
if (teamWithBilling?.platformBilling) {
|
if (teamWithBilling?.platformBilling) {
|
||||||
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
||||||
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
|
return { team: teamWithBilling, status: "no_subscription" as const, plan: "none" };
|
||||||
}
|
|
||||||
|
|
||||||
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
|
|
||||||
} else {
|
} else {
|
||||||
return { team: teamWithBilling, status: "no_billing", plan: "none" };
|
return {
|
||||||
|
team: teamWithBilling,
|
||||||
|
status: "valid" as const,
|
||||||
|
plan: teamWithBilling.platformBilling.plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { team: teamWithBilling, status: "no_billing" as const, plan: "none" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTeamBilling(teamId: number) {
|
async createTeamBilling(teamId: number): Promise<string> {
|
||||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return customerId;
|
return customerId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PlatformPlan } from "@/modules/billing/types";
|
import { hasMinimumPlan } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { orderedPlans, PlatformPlan } from "@/modules/billing/types";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManagedOrganizationsBillingService {
|
export class ManagedOrganizationsBillingService {
|
||||||
@@ -14,8 +15,18 @@ export class ManagedOrganizationsBillingService {
|
|||||||
if (!managerOrgBilling) {
|
if (!managerOrgBilling) {
|
||||||
throw new NotFoundException("Manager organization billing not found.");
|
throw new NotFoundException("Manager organization billing not found.");
|
||||||
}
|
}
|
||||||
if (managerOrgBilling.plan !== PlatformPlan.SCALE) {
|
if (
|
||||||
throw new NotFoundException("Manager organization billing plan is not SCALE.");
|
!hasMinimumPlan({
|
||||||
|
currentPlan: managerOrgBilling.plan as PlatformPlan,
|
||||||
|
minimumPlan: PlatformPlan.SCALE,
|
||||||
|
plans: orderedPlans,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`organization with id=${managerOrgId} does not have required plan for this operation. Minimum plan is ${
|
||||||
|
PlatformPlan.SCALE
|
||||||
|
} while the organization has ${managerOrgBilling.plan || "undefined"}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dbWrite.prisma.platformBilling.create({
|
return this.dbWrite.prisma.platformBilling.create({
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
@@ -297,6 +297,12 @@ export class DateTimeWithZone {
|
|||||||
timeZone!: string;
|
timeZone!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiExtraModels(
|
||||||
|
CalendarEventVideoLocation,
|
||||||
|
CalendarEventPhoneLocation,
|
||||||
|
CalendarEventSipLocation,
|
||||||
|
CalendarEventMoreLocation
|
||||||
|
)
|
||||||
export class UnifiedCalendarEventOutput {
|
export class UnifiedCalendarEventOutput {
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => DateTimeWithZone)
|
@Type(() => DateTimeWithZone)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user