Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a5d8b261 | ||
|
|
dfc022d890 | ||
|
|
68cb0cf197 | ||
|
|
14592de6c4 | ||
|
|
d41dcc69ad | ||
|
|
0a28b5825b | ||
|
|
3131318b6a | ||
|
|
08e35e4698 | ||
|
|
ed4b3f082a | ||
|
|
a1007398f6 | ||
|
|
2aafa1c163 | ||
|
|
008178480a | ||
|
|
2334c3bef8 | ||
|
|
9542116ec1 | ||
|
|
f9c11c2856 | ||
|
|
30060f03eb | ||
|
|
b8ffcef45e | ||
|
|
999882b795 | ||
|
|
bda963b851 | ||
|
|
8511494d68 | ||
|
|
1c3ad9dad6 | ||
|
|
d43afe7dbd | ||
|
|
a06b2009ab | ||
|
|
2a786b4652 | ||
|
|
ef1844557f | ||
|
|
d1f7b043e8 | ||
|
|
3a8e40d029 | ||
|
|
bca46228e7 | ||
|
|
d15b24b8ea | ||
|
|
d9df9b3d01 | ||
|
|
fc1d6ae402 | ||
|
|
26b4b7a868 | ||
|
|
a839630ca3 | ||
|
|
e21dbb704e | ||
|
|
53a06797a9 | ||
|
|
99eb743772 | ||
|
|
73adfb0cec | ||
|
|
7127c60116 | ||
|
|
a6d37fd167 | ||
|
|
16ea15076d | ||
|
|
041871f1b6 | ||
|
|
8c8ae127fe | ||
|
|
357c82129c | ||
|
|
324ac691eb | ||
|
|
da99001a7a | ||
|
|
bd10d07083 | ||
|
|
0e9b419a07 | ||
|
|
d5bd08d279 | ||
|
|
4cc83530f9 | ||
|
|
4fcf4081d0 | ||
|
|
d84d80eea6 | ||
|
|
f3e1d67de1 | ||
|
|
2ce25f54d4 | ||
|
|
25eac5d29b | ||
|
|
1710e30719 | ||
|
|
6d970d0b57 | ||
|
|
41a6035a12 | ||
|
|
f84c8239e7 | ||
|
|
1dca160db0 | ||
|
|
4ff366cd99 | ||
|
|
21a4711f07 | ||
|
|
f1dd217de7 | ||
|
|
3e8f0b125d | ||
|
|
25246ef8ff | ||
|
|
bb98143e2e | ||
|
|
6258ca14d0 | ||
|
|
d0e17e8611 | ||
|
|
79dc76239b | ||
|
|
95d62354d9 | ||
|
|
b9fadf82a7 | ||
|
|
1740e34c79 | ||
|
|
9e09066e99 | ||
|
|
0a9cfd3a08 | ||
|
|
0be711ad6e | ||
|
|
1c507b8169 | ||
|
|
81bdb3dc91 | ||
|
|
5efc7f3856 | ||
|
|
0afecec607 | ||
|
|
920574de65 | ||
|
|
d15490d5aa | ||
|
|
ba6e547f6c | ||
|
|
2493315f92 | ||
|
|
ec117d4034 | ||
|
|
646058b8a1 | ||
|
|
06c359ac9b | ||
|
|
0d96d9c44c | ||
|
|
e5f8d4a1ac | ||
|
|
19c72323b8 | ||
|
|
f19b2804b6 | ||
|
|
f387fdb166 | ||
|
|
ec8f9d5f7a | ||
|
|
6cb3bdc567 | ||
|
|
d574840c3f | ||
|
|
6a85d0e10b | ||
|
|
816df3704e | ||
|
|
badd9a5698 | ||
|
|
4e02fe4251 | ||
|
|
01b9780938 | ||
|
|
6be144f95f | ||
|
|
37dd6d888b | ||
|
|
f5ffee0a20 | ||
|
|
6cd51e0a8d | ||
|
|
80fad2aab9 | ||
|
|
1ccfc65766 | ||
|
|
86918b03cf | ||
|
|
a5b684af1f | ||
|
|
8889e8566c | ||
|
|
fab364f084 | ||
|
|
5bc9d71a88 | ||
|
|
2b5ea649d4 | ||
|
|
0802ebb17b | ||
|
|
29b8d68f45 | ||
|
|
6994cac5e0 | ||
|
|
df9b72583d | ||
|
|
553eab59e6 | ||
|
|
8d675fd8b6 | ||
|
|
a5639a8290 | ||
|
|
44d9c4bcc5 | ||
|
|
1b586560f8 | ||
|
|
e082cc7d3d | ||
|
|
d411e70ea3 | ||
|
|
8dffa4d371 | ||
|
|
b7bf6e80e6 | ||
|
|
2a93e661f9 | ||
|
|
bfb51b82b2 | ||
|
|
f50be6c08b | ||
|
|
df75df7008 | ||
|
|
deb55c0536 | ||
|
|
7fc11ff341 | ||
|
|
1a70f83d34 | ||
|
|
39077a08f1 | ||
|
|
9a671f9063 | ||
|
|
3bc9fa11d0 | ||
|
|
7e699ab810 | ||
|
|
1bd17ba365 | ||
|
|
4d5246cf4d | ||
|
|
5d988453fa | ||
|
|
85b9782264 | ||
|
|
c2b091aa67 | ||
|
|
7093e1e4c6 | ||
|
|
ead42c1a9c | ||
|
|
e669996442 | ||
|
|
0e6f365dc2 | ||
|
|
08b501625d | ||
|
|
b89ff43921 | ||
|
|
fadfba8830 | ||
|
|
43be9bd6a8 | ||
|
|
caf184151d | ||
|
|
22e3338709 | ||
|
|
048e56806c | ||
|
|
ade64c0d09 | ||
|
|
5beb2943b9 | ||
|
|
b3d3cb1dee | ||
|
|
79d695236f | ||
|
|
2dbbdc21f2 | ||
|
|
e4254ef7cc | ||
|
|
7f9709bdaa | ||
|
|
77054d92df | ||
|
|
1277f5e9f7 | ||
|
|
109c303b60 | ||
|
|
a52a0e90e7 | ||
|
+25 |
558140fdd6 | ||
|
|
fa09fd66ff | ||
|
|
b1cbeebe48 | ||
|
|
93bad282d7 | ||
|
|
47d941676e | ||
|
|
54c4b0b65e | ||
|
|
0f8356f417 | ||
|
|
cecfcc7c6f | ||
|
|
0b3f9d06a9 | ||
|
|
71e7300830 | ||
|
|
bccffb328b | ||
|
|
80fa172f79 | ||
|
|
50173658f8 | ||
|
|
dddbd7dad4 | ||
|
|
ae2d85d81b | ||
|
|
80c407a4b7 | ||
|
|
6e9ec113fe | ||
|
|
3ee6a7ecde | ||
|
|
8ad62d183c | ||
|
|
f047799299 | ||
|
|
3a9e353443 | ||
|
|
b73a3aeb0c | ||
|
|
b6ff64ed74 | ||
|
|
1b281fe269 | ||
|
|
024913592b | ||
|
|
78a34038d2 | ||
|
|
5f770a9789 | ||
|
|
24a43877ce | ||
|
|
4a96be4175 | ||
|
|
594bb606b8 | ||
|
|
1c088f65c5 | ||
|
|
7d7a429780 | ||
|
|
d3228441aa | ||
|
|
21d6bf1fca | ||
|
|
a4134aa5a3 | ||
|
|
61397e5184 | ||
|
|
6143cd60e5 | ||
|
|
ac821f6b13 | ||
|
|
2bce38fd2a | ||
|
|
390ee79134 | ||
|
|
cfc9f0bfc3 | ||
|
|
58d5f2a17c | ||
|
|
37f271bd74 | ||
|
|
d770cfc65e | ||
|
|
ab8556790e | ||
|
|
3d89b37cc6 | ||
|
|
78b213321c | ||
|
|
276ba1179f | ||
|
|
0109d166a1 | ||
|
|
779c014256 | ||
|
|
073faf9e10 | ||
|
|
d0e39cf2dd | ||
|
|
f9488a1866 | ||
|
|
d4a3894a9e | ||
|
|
91288b41cd | ||
|
|
1aa2f68e73 | ||
|
|
ad707b0ee4 | ||
|
|
49a2f4d4f7 | ||
|
|
f576979345 | ||
|
|
b45399ab78 | ||
|
|
306dc5e4fb | ||
|
|
605304b93b | ||
|
|
d6a17b2b1e | ||
|
|
4bee875a8c | ||
|
|
fe4ae3ae62 | ||
|
|
a245578ff2 | ||
|
|
f5ec696e15 | ||
|
|
ec9384858b | ||
|
|
47613f796e | ||
|
|
446c780d85 | ||
|
|
615968c640 | ||
|
|
fc89436b6a | ||
|
|
3c1015cb11 | ||
|
|
46c513330c | ||
|
|
ef1598faeb | ||
|
|
42a6903057 | ||
|
|
0f0c2b8b92 | ||
|
|
c400c36306 | ||
|
|
6410bd2eaf | ||
|
|
2ddc5317b8 | ||
|
|
6fffe3377d | ||
|
|
eedef35d02 | ||
|
|
08d232a816 | ||
|
|
d9b3f8657f | ||
|
|
99dc5837f5 | ||
|
|
5a35540522 | ||
|
|
c01fc08f67 | ||
|
|
7397722eb3 | ||
|
|
d51e03b14b | ||
|
|
c4af180a5b | ||
|
|
ee5f1dc879 | ||
|
|
15b43176ff | ||
|
|
03240709dc | ||
|
|
7ee451ca02 | ||
|
|
55b066c475 | ||
|
|
c52bbc4be3 | ||
|
|
31b1f31d82 | ||
|
|
ab1c1bb015 | ||
|
|
45219f4736 | ||
|
|
3296255f80 | ||
|
|
81b4651d6e | ||
|
|
e3bd90c4f2 | ||
|
|
17f1932467 | ||
|
|
1b04fe82a7 | ||
|
|
ffb36509ff | ||
|
|
dc361f7879 | ||
|
|
0b59a5d184 | ||
|
|
54c17eb827 | ||
|
|
bd1142ea7a | ||
|
|
26051833cf | ||
|
|
1d8bc6ca49 | ||
|
|
fbe8cf8223 | ||
|
|
ef8b3e83ac | ||
|
|
9d228a1d96 | ||
|
|
57df25505d | ||
|
|
31cbea0e23 | ||
|
|
edd80c056c | ||
|
|
6ebf0db389 | ||
|
|
6f4e51b859 | ||
|
|
4f5d0db792 | ||
|
|
8733bcaf7a | ||
|
|
5aaca2b7aa | ||
|
|
eacd05bf3b | ||
|
|
d51e0182b5 | ||
|
|
220897ba86 | ||
|
|
78c0203a45 | ||
|
|
720fd90066 | ||
|
|
ca8ee749a2 | ||
|
|
ce2d3a4b00 | ||
|
|
2923684c7c | ||
|
|
879ae597be | ||
|
|
d21f7ccbc8 | ||
|
|
4cc1bc6746 | ||
|
|
49a2c60212 | ||
|
|
7dfbb42ced | ||
|
|
41fafb9e5e | ||
|
|
6815f2f9c7 | ||
|
|
91ab27e070 | ||
|
|
396ccb1cd2 | ||
|
|
4e8d5cebca | ||
|
|
80f2e61189 | ||
|
|
d844fd6ef7 | ||
|
|
513f54f4fe | ||
|
|
2e6d71f493 | ||
|
|
55488c39f4 | ||
|
|
c0e123a917 | ||
|
|
ec88f16f1f | ||
|
|
a957c15062 | ||
|
|
b32b31ccdc | ||
|
|
e29f6af540 | ||
|
|
c6cb3423fa | ||
|
|
45904c07bc | ||
|
|
0623887ff8 | ||
|
|
f31c45a153 | ||
|
|
f5217caef2 | ||
|
|
e1beec0487 | ||
|
|
6bbf263981 | ||
|
|
d86a28bf7f | ||
|
|
c00e8cfa54 | ||
|
|
e82cc328d3 | ||
|
|
7dbfc1b2e0 | ||
|
|
02fb7c0ae0 | ||
|
|
9004dcb93b | ||
|
|
cf83b0a236 | ||
|
|
7f02464fed | ||
|
|
2371253cb5 | ||
|
|
859e7f1c88 | ||
|
|
f1e8481878 | ||
|
|
acf3a7c54e | ||
|
|
f163e4638b | ||
|
|
9dab915ddd | ||
|
|
8fca8f3910 | ||
|
|
1638465607 | ||
|
|
ab7eb7f4b1 | ||
|
|
0592badd56 | ||
|
|
7e82b8fa86 | ||
|
|
7dfb71ca30 | ||
|
|
d99ecde0e2 | ||
|
|
d81b905ad7 | ||
|
|
46c6582328 | ||
|
|
396811b954 | ||
|
|
f6c7d8d97f | ||
|
|
930165b6e8 | ||
|
|
a77bd7bbe0 | ||
|
|
51212cdacf | ||
|
|
3731b1ef3d | ||
|
|
f631c4aa79 | ||
|
|
95cd76e732 | ||
|
|
7e525779b7 | ||
|
|
f176ebf1da | ||
|
|
5058e0e750 | ||
|
|
dc409e1c5a | ||
|
|
89b5d1ebc3 | ||
|
|
0d24664a72 | ||
|
|
69efc26f36 | ||
|
|
95c48ac805 | ||
|
|
59ab38db98 | ||
|
|
11a41e0e39 | ||
|
|
880fe151c0 | ||
|
|
603f106389 | ||
|
|
5c7f1aebb2 | ||
|
|
a8fe3a6466 | ||
|
|
8974f8f9de | ||
|
|
a11c2cc52c | ||
|
|
14d319f992 | ||
|
|
49f0759f31 | ||
|
|
cae8470350 | ||
|
|
a82d616b79 | ||
|
|
515f3263e0 | ||
|
|
aebbc50ba6 | ||
|
|
7cdc78d02d | ||
|
|
df55dec553 | ||
|
|
398ad22868 | ||
|
|
7ef1050946 | ||
|
|
7b8bbaaa86 | ||
|
|
ddfd3fda70 | ||
|
|
5972a6f77f | ||
|
|
9bc703a437 | ||
|
|
15aaf7f51b | ||
|
|
c89a1877ae | ||
|
|
15d2c0f858 | ||
|
|
43a6663230 | ||
|
|
58315e96c9 | ||
|
|
3e466022b8 | ||
|
|
68c036cd0a | ||
|
|
77b656024a | ||
|
|
89772a0dac | ||
|
|
381c462660 | ||
|
|
417acdaacb | ||
|
|
1584dd3179 | ||
|
|
d66f983f56 | ||
|
|
b1f50fe22d | ||
|
|
ce995c39f2 | ||
|
|
b99ea16b31 | ||
|
|
574d8882f4 |
No files matched your search
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||||
"changelog": "@changesets/cli/changelog",
|
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
|
||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": []
|
"ignore": [],
|
||||||
|
"privatePackages": {
|
||||||
|
"version": false,
|
||||||
|
"tag": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+22
-7
@@ -159,13 +159,17 @@ SENDGRID_EMAIL=
|
|||||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
|
SENTRY_ORG=
|
||||||
|
SENTRY_PROJECT=
|
||||||
|
SENTRY_AUTH_TOKEN=
|
||||||
# Used for capturing exceptions and logging messages
|
# Used for capturing exceptions and logging messages
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
SENTRY_DEBUG=
|
SENTRY_DEBUG=
|
||||||
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
|
|
||||||
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
|
|
||||||
SENTRY_MAX_SPANS=
|
SENTRY_MAX_SPANS=
|
||||||
|
SENTRY_SAMPLE_RATE=
|
||||||
SENTRY_TRACES_SAMPLE_RATE=
|
SENTRY_TRACES_SAMPLE_RATE=
|
||||||
|
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
|
||||||
|
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
|
||||||
|
|
||||||
# Formbricks Experience Management Integration
|
# Formbricks Experience Management Integration
|
||||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||||
@@ -184,9 +188,14 @@ TWILIO_TOKEN=
|
|||||||
TWILIO_MESSAGING_SID=
|
TWILIO_MESSAGING_SID=
|
||||||
TWILIO_PHONE_NUMBER=
|
TWILIO_PHONE_NUMBER=
|
||||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||||
|
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
|
||||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
|
TWILIO_OPT_OUT_ENABLED=
|
||||||
|
|
||||||
# Set it to "1" if you need to run E2E tests locally.
|
# Set it to "1" if you need to run E2E tests locally.
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
@@ -197,6 +206,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
|||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||||
STRIPE_TEAM_PRODUCT_ID=
|
STRIPE_TEAM_PRODUCT_ID=
|
||||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
@@ -362,9 +372,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
|||||||
|
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# disable setry server source maps
|
|
||||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
|
||||||
|
|
||||||
# api v2
|
# api v2
|
||||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||||
|
|
||||||
@@ -424,7 +431,7 @@ DIRECTORY_IDS_TO_LOG=
|
|||||||
# Read more about it in the README.md
|
# Read more about it in the README.md
|
||||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||||
|
|
||||||
AKISMET_API_KEY=
|
IFFY_API_KEY=
|
||||||
|
|
||||||
## Env variables related to avoiding booking failures
|
## Env variables related to avoiding booking failures
|
||||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||||
@@ -438,8 +445,16 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
|
|||||||
# Used to enable quick availability checks for x% of all visitors
|
# Used to enable quick availability checks for x% of all visitors
|
||||||
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||||
|
|
||||||
|
# Used to generate token for cal video recordings expiry token
|
||||||
|
# You can use: `openssl rand -base64 32` to generate one
|
||||||
|
CAL_VIDEO_RECORDING_TOKEN_SECRET=
|
||||||
|
|
||||||
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
||||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||||
|
|
||||||
DATABASE_CHUNK_SIZE=
|
DATABASE_CHUNK_SIZE=
|
||||||
|
|
||||||
|
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||||
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
|
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||||
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
|
/apps/web/lib/**/* @calcom/Foundation
|
||||||
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
/infra/**/* @calcom/Foundation
|
/infra/**/* @calcom/Foundation
|
||||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||||
@@ -20,4 +24,7 @@
|
|||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/Foundation
|
||||||
|
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||||
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
|
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||||
|
permissions:
|
||||||
|
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||||
|
contents: write # to create release by changesets/action below
|
||||||
|
pull-requests: write # to create pull request by changesets/action below
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup node, yarn and install dependencies
|
||||||
|
uses: ./.github/actions/yarn-install
|
||||||
|
- name: Create Release Pull Request or Publish to npm
|
||||||
|
id: changesets
|
||||||
|
uses: changesets/action@v1
|
||||||
|
with:
|
||||||
|
version: yarn changesets-version
|
||||||
|
publish: yarn changesets-release
|
||||||
|
commit: "chore: version packages" # commit message
|
||||||
|
title: "chore: version packages" # PR title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
jobs:
|
||||||
|
cleanup-report:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout GitHub Pages Branch
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: calcom/test-results
|
||||||
|
ref: gh-pages
|
||||||
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set Git User
|
||||||
|
run: |
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Cleanup HTML reports on PR close/merge
|
||||||
|
env:
|
||||||
|
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
|
||||||
|
run: |
|
||||||
|
if [ -d "$BRANCH_REPORTS_DIR" ]; then
|
||||||
|
rm -rf "$BRANCH_REPORTS_DIR"
|
||||||
|
git add .
|
||||||
|
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
|
||||||
|
git push
|
||||||
|
else
|
||||||
|
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Cron - checkSmsPrices
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs “every minute” (see https://crontab.guru)
|
||||||
|
- cron: "* * * * *"
|
||||||
|
jobs:
|
||||||
|
cron-checkSmsPrices:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/checkSmsPrices \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -219,6 +219,17 @@ jobs:
|
|||||||
uses: ./.github/workflows/publish-report.yml
|
uses: ./.github/workflows/publish-report.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
cleanup-report:
|
||||||
|
name: Cleanup HTML report
|
||||||
|
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
needs: [changes, check-label, publish-report]
|
||||||
|
uses: ./.github/workflows/cleanup-report.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs:
|
needs:
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
name: Submodule Sync
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
|
|
||||||
- cron: "15 */4 * * *"
|
|
||||||
workflow_dispatch: ~
|
|
||||||
jobs:
|
|
||||||
submodule-sync:
|
|
||||||
name: Submodule update
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
- name: Commit
|
|
||||||
run: |
|
|
||||||
git config user.email "actions@github.com"
|
|
||||||
git config user.name "github-actions"
|
|
||||||
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
echo "Updating submodules recursively"
|
|
||||||
pwd
|
|
||||||
git submodule update --init --recursive
|
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Whitespace-only changes.
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Whitespace-only changes.
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
Whitespace-only changes.
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Whitespace-only changes.
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Whitespace-only changes.
Vendored
Whitespace-only changes.
Vendored
Whitespace-only changes.
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
# Cal.com Permission Documentation
|
||||||
|
|
||||||
|
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
|
||||||
|
|
||||||
|
## Permissions by Resource
|
||||||
|
|
||||||
|
### Team Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
|
||||||
|
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
|
||||||
|
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
|
||||||
|
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
|
||||||
|
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
|
||||||
|
|
||||||
|
### Event Type Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
|
||||||
|
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
|
||||||
|
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
|
||||||
|
|
||||||
|
### Booking Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
|
||||||
|
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||||
|
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||||
|
|
||||||
|
### Organization Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
|
||||||
|
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
|
||||||
|
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
|
||||||
|
|
||||||
|
### API Key Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
|
||||||
|
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
|
||||||
|
|
||||||
|
## Helper Functions and Utilities
|
||||||
|
|
||||||
|
The following helper functions are commonly used for permission checks:
|
||||||
|
|
||||||
|
| Function | Description | File Path |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
|
||||||
|
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
|
||||||
|
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
|
||||||
|
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
|
||||||
|
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
|
||||||
|
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
|
||||||
|
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
|
||||||
|
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
|
||||||
|
|
||||||
|
## Permission Level Enums
|
||||||
|
|
||||||
|
The codebase uses several enums to define permission levels:
|
||||||
|
|
||||||
|
### MembershipRole Enum
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum MembershipRole {
|
||||||
|
OWNER = "OWNER",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
MEMBER = "MEMBER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entity Permission Level Enum
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ENTITY_PERMISSION_LEVEL {
|
||||||
|
NONE,
|
||||||
|
USER_ONLY_WRITE,
|
||||||
|
TEAM_READ_ONLY,
|
||||||
|
TEAM_WRITE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Permission Patterns
|
||||||
|
|
||||||
|
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
|
||||||
|
```typescript
|
||||||
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
|
||||||
|
```typescript
|
||||||
|
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
|
||||||
|
```typescript
|
||||||
|
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Entity Permission Check**: Entity operations use permission level checks.
|
||||||
|
```typescript
|
||||||
|
const permissionLevel = await getEntityPermissionLevel(entity, userId);
|
||||||
|
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
|
||||||
|
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
|
||||||
|
```typescript
|
||||||
|
const membershipIdsWhereUserIsAdminOwner = (
|
||||||
|
await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
role: {
|
||||||
|
in: ["ADMIN", "OWNER"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((membership) => membership.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration to PBAC
|
||||||
|
|
||||||
|
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
|
||||||
|
|
||||||
|
// Instead of:
|
||||||
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission String Alternatives for Helper Functions
|
||||||
|
|
||||||
|
The following table provides permission string alternatives for common helper functions:
|
||||||
|
|
||||||
|
| Helper Function | Permission String Alternative | Description |
|
||||||
|
|-----------------|-------------------------------|-------------|
|
||||||
|
| isTeamAdmin | `team.*` | Grants all team permissions |
|
||||||
|
| isTeamAdmin | `team.update` | Update team settings |
|
||||||
|
| isTeamAdmin | `team.invite` | Invite team members |
|
||||||
|
| isTeamAdmin | `team.remove` | Remove team members |
|
||||||
|
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
|
||||||
|
| isTeamOwner | `team.delete` | Delete team |
|
||||||
|
| isTeamMember | `team.read` | Read-only access to team |
|
||||||
|
|
||||||
|
## Entity Permission Functions and Resources
|
||||||
|
|
||||||
|
The following functions are used to check permissions for entities with userId and teamId properties:
|
||||||
|
|
||||||
|
| Function | Description | Permission String Alternative |
|
||||||
|
|----------|-------------|-------------------------------|
|
||||||
|
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
|
||||||
|
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
|
||||||
|
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
|
||||||
|
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
|
||||||
|
|
||||||
|
### Resources Used with Entity Permission Functions
|
||||||
|
|
||||||
|
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|
||||||
|
|----------|-------------------|---------------------|------------------|
|
||||||
|
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
|
||||||
|
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
|
||||||
|
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
|
||||||
|
|
||||||
|
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Instead of:
|
||||||
|
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows for more granular permission control and the creation of custom roles with specific permissions.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Possible issue:
|
||||||
|
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
|
||||||
|
|
||||||
|
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
|
||||||
|
- Delete Credentials when DelegationCredential is disabled
|
||||||
|
|
||||||
|
|
||||||
|
TO Test
|
||||||
|
- New members beyond the batch size are processed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -646,6 +646,14 @@ following
|
|||||||
15. Create a verify service
|
15. Create a verify service
|
||||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||||
|
|
||||||
|
## Changesets
|
||||||
|
|
||||||
|
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||||
|
|
||||||
|
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||||
|
1. Follow semantic versioning when using changesets.
|
||||||
|
2. Mark breaking changes using `❗️Breaking change`
|
||||||
|
|
||||||
<!-- LICENSE -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Constructs the WHERE clause for Prisma booking findMany operation.
|
||||||
|
*
|
||||||
|
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
||||||
|
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
||||||
|
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
||||||
|
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
||||||
|
*
|
||||||
|
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
||||||
|
*/
|
||||||
|
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
|
||||||
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
||||||
|
|
||||||
|
let whereClause = {};
|
||||||
|
|
||||||
|
if (filterByAttendeeEmails) {
|
||||||
|
whereClause = {
|
||||||
|
AND: [
|
||||||
|
userFilter,
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: attendeeEmails },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
whereClause = {
|
||||||
|
...userFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return whereClause;
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
|
|||||||
id: true,
|
id: true,
|
||||||
// No eventTypeId support in API v1
|
// No eventTypeId support in API v1
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
|
|
||||||
|
/** No watch related fields support in API v1 */
|
||||||
|
watchAttempts: true,
|
||||||
|
unwatchAttempts: true,
|
||||||
|
maxAttempts: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
|
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@calcom/lib": "*",
|
"@calcom/lib": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@sentry/nextjs": "^8.8.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.9",
|
"next": "^13.5.9",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^5.8.3",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma, Booking } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
import {
|
import {
|
||||||
getAccessibleUsers,
|
getAccessibleUsers,
|
||||||
retrieveOrgScopedAccessibleUsers,
|
retrieveOrgScopedAccessibleUsers,
|
||||||
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs the WHERE clause for Prisma booking findMany operation.
|
|
||||||
*
|
|
||||||
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
|
||||||
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
|
||||||
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
|
||||||
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
|
||||||
*
|
|
||||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
|
||||||
*/
|
|
||||||
function buildWhereClause(
|
|
||||||
userId: number | null,
|
|
||||||
attendeeEmails: string[],
|
|
||||||
userIds: number[] = [],
|
|
||||||
userEmails: string[] = []
|
|
||||||
) {
|
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
|
||||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
|
||||||
|
|
||||||
let whereClause = {};
|
|
||||||
|
|
||||||
if (filterByAttendeeEmails) {
|
|
||||||
whereClause = {
|
|
||||||
AND: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: attendeeEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
whereClause = {
|
|
||||||
OR: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: userEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereClause;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handler(req: NextApiRequest) {
|
export async function handler(req: NextApiRequest) {
|
||||||
const {
|
const {
|
||||||
userId,
|
userId,
|
||||||
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
? [queryFilterForAttendeeEmails.attendeeEmail]
|
? [queryFilterForAttendeeEmails.attendeeEmail]
|
||||||
: [];
|
: [];
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
let userEmailsToFilterBy: string[] = [];
|
||||||
|
|
||||||
/** Only admins can query other users */
|
/** Only admins can query other users */
|
||||||
if (isSystemWideAdmin) {
|
if (isSystemWideAdmin) {
|
||||||
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
}
|
}
|
||||||
} else if (isOrganizationOwnerOrAdmin) {
|
} else if (isOrganizationOwnerOrAdmin) {
|
||||||
let requestedUserIds = [userId];
|
let requestedUserIds = [userId];
|
||||||
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
} else {
|
} else {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new HttpError({ message: "User not found", statusCode: 404 });
|
throw new HttpError({ message: "User not found", statusCode: 404 });
|
||||||
}
|
}
|
||||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
args.where = buildWhereClause(userId, attendeeEmails, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.booking.findMany(args);
|
let data: Booking[] = [];
|
||||||
|
|
||||||
|
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
|
||||||
|
const queryOne = prisma.booking.findMany(args);
|
||||||
|
|
||||||
|
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: userEmailsToFilterBy },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: dateFrom };
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauseForQueryTwo.endTime = { lte: dateTo };
|
||||||
|
}
|
||||||
|
if (status === "upcoming") {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
|
||||||
|
...args,
|
||||||
|
where: whereClauseForQueryTwo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
|
||||||
|
|
||||||
|
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
|
||||||
|
|
||||||
|
const bookingMap = new Map();
|
||||||
|
[...resultOne, ...resultTwo].forEach((booking) => {
|
||||||
|
bookingMap.set(booking.id, booking);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dedupedResults = Array.from(bookingMap.values());
|
||||||
|
|
||||||
|
if (args.orderBy) {
|
||||||
|
let sortField: keyof Booking;
|
||||||
|
let sortDirection: "asc" | "desc" = "asc";
|
||||||
|
|
||||||
|
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
|
||||||
|
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
|
||||||
|
sortField = orderByKey as keyof Booking;
|
||||||
|
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
|
||||||
|
} else {
|
||||||
|
sortField = "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = sortDirection === "desc" ? -1 : 1;
|
||||||
|
|
||||||
|
dedupedResults.sort((a, b) => {
|
||||||
|
const aValue = a[sortField];
|
||||||
|
const bValue = b[sortField];
|
||||||
|
|
||||||
|
if (aValue < bValue) return -1 * sortOrder;
|
||||||
|
if (aValue > bValue) return 1 * sortOrder;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.take !== undefined && args.skip !== undefined) {
|
||||||
|
data = dedupedResults.slice(args.skip, args.skip + args.take);
|
||||||
|
} else {
|
||||||
|
data = dedupedResults;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await prisma.booking.findMany(args);
|
||||||
|
}
|
||||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
|
|||||||
currentCredentialId: number | null;
|
currentCredentialId: number | null;
|
||||||
}) {
|
}) {
|
||||||
if (parsedBody.integration && parsedBody.externalId) {
|
if (parsedBody.integration && parsedBody.externalId) {
|
||||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
|
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
|
||||||
|
userCredentials.map((cred) => ({
|
||||||
|
...cred,
|
||||||
|
delegationCredentialId: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const { connectedCalendars } = await getConnectedCalendars(
|
const { connectedCalendars } = await getConnectedCalendars(
|
||||||
calendarCredentials,
|
calendarCredentials,
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
|
|||||||
*/
|
*/
|
||||||
async function postHandler(req: NextApiRequest) {
|
async function postHandler(req: NextApiRequest) {
|
||||||
const data = membershipCreateBodySchema.parse(req.body);
|
const data = membershipCreateBodySchema.parse(req.body);
|
||||||
const args: Prisma.MembershipCreateArgs = { data };
|
const args: Prisma.MembershipCreateArgs = {
|
||||||
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import utc from "dayjs/plugin/utc";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||||
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 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ describe("GET /api/bookings", async () => {
|
|||||||
|
|
||||||
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
|
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
|
||||||
expect(groupedUsers.size).toBe(1);
|
expect(groupedUsers.size).toBe(1);
|
||||||
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
|
const firstEntry = groupedUsers.entries().next().value;
|
||||||
|
expect(firstEntry?.[0]).toBe(memberUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Returns bookings for regular user", async () => {
|
it("Returns bookings for regular user", async () => {
|
||||||
@@ -206,8 +208,155 @@ describe("GET /api/bookings", async () => {
|
|||||||
console.log("bookings=>", responseData.bookings);
|
console.log("bookings=>", responseData.bookings);
|
||||||
responseData.bookings.forEach((booking) => {
|
responseData.bookings.forEach((booking) => {
|
||||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
if (booking.id === 19) {
|
||||||
|
// The team field can be either null or undefined due to nullish() in the schema
|
||||||
|
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Date filtering", () => {
|
||||||
|
it("filters bookings by dateFrom", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "2023-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
|
||||||
|
new Date("2023-01-01T00:00:00Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters bookings by dateTo", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "2024-12-31T23:59:59Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
|
||||||
|
new Date("2024-12-31T23:59:59Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sorting and ordering", () => {
|
||||||
|
it("sorts bookings by createdAt in descending order", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
|
||||||
|
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple attendee email filtering", () => {
|
||||||
|
it("filters bookings by multiple attendee emails", async () => {
|
||||||
|
const attendeeEmails = ["test1@example.com", "test2@example.com"];
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: attendeeEmails,
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
|
||||||
|
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error cases", () => {
|
||||||
|
it("throws error for invalid status parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Result merging", () => {
|
||||||
|
it("does not return duplicate bookings when merging results from multiple queries", async () => {
|
||||||
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|
||||||
|
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||||
|
|
||||||
|
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
||||||
|
where: { userId: testUser.id },
|
||||||
|
include: { attendees: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 100,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
|
||||||
|
const bookingIds = responseData.bookings.map((booking) => booking.id);
|
||||||
|
|
||||||
|
const uniqueBookingIds = new Set(bookingIds);
|
||||||
|
|
||||||
|
expect(uniqueBookingIds.size).toBe(bookingIds.length);
|
||||||
|
|
||||||
|
if (uniqueBookingIds.size !== bookingIds.length) {
|
||||||
|
const counts = bookingIds.reduce((acc, id) => {
|
||||||
|
acc[id] = (acc[id] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
const duplicates = Object.entries(counts)
|
||||||
|
.filter(([_, count]) => count > 1)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
|
|
||||||
|
describe("buildWhereClause", () => {
|
||||||
|
it("should return empty object when no filters are provided", () => {
|
||||||
|
const result = buildWhereClause(null, [], []);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userId when only userId is provided", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const result = buildWhereClause(userId, [], []);
|
||||||
|
expect(result).toEqual({ userId: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userIds when userIds array is provided", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const result = buildWhereClause(null, [], userIds);
|
||||||
|
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by attendee emails when provided", () => {
|
||||||
|
const attendeeEmails = ["test@example.com", "user@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com", "user@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userId and attendee email filters", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(userId, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: 123 },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userIds and attendee email filters", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, userIds);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: { in: [1, 2, 3] } },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
googleChannelResourceUri: null,
|
googleChannelResourceUri: null,
|
||||||
googleChannelExpiration: null,
|
googleChannelExpiration: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
lastErrorAt: null,
|
||||||
|
watchAttempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
unwatchAttempts: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
domainWideDelegationCredentialId: null,
|
domainWideDelegationCredentialId: null,
|
||||||
eventTypeId: null,
|
eventTypeId: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
lastErrorAt: null,
|
||||||
|
watchAttempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
unwatchAttempts: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ $ yarn test:e2e:watch --testPathPattern=filePath
|
|||||||
$ yarn run test:cov
|
$ yarn run test:cov
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Guards
|
||||||
|
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||||
|
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||||
|
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
|
||||||
|
all auth methods are allowed.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
"reporters": ["default", "jest-summarizing-reporter"],
|
||||||
"workerIdleMemoryLimit": "512MB",
|
"workerIdleMemoryLimit": "512MB",
|
||||||
"maxWorkers": 8
|
"maxWorkers": 8
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Platform API for Cal.com",
|
"description": "Platform API for Cal.com",
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"private": true,
|
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn dev:build && nest build",
|
"build": "yarn dev:build && nest build",
|
||||||
@@ -38,7 +37,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.172",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
@@ -46,6 +45,7 @@
|
|||||||
"@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",
|
||||||
|
"@nestjs/axios": "^4.0.0",
|
||||||
"@nestjs/bull": "^10.1.1",
|
"@nestjs/bull": "^10.1.1",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.1.0",
|
"tsconfig-paths": "^4.1.0",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"schema": "../../../packages/prisma/schema.prisma"
|
"schema": "../../../packages/prisma/schema.prisma"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
KyselyModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly oAuthFlowService: OAuthFlowService,
|
private readonly oAuthFlowService: OAuthFlowService,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly kyselyReadService: KyselyReadService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
ctx: {
|
ctx: {
|
||||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
kysely: this.kyselyReadService.kysely,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingReferencesRepository_2024_08_13 {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
|
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||||
|
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
|
||||||
|
|
||||||
|
if (filter?.type) {
|
||||||
|
whereClause.type = filter.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dbRead.prisma.bookingReference.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
uid: true,
|
||||||
|
id: true,
|
||||||
|
externalCalendarId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
@@ -16,12 +20,18 @@ import { BillingModule } from "@/modules/billing/billing.module";
|
|||||||
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { KyselyModule } from "@/modules/kysely/kysely.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
|
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -30,6 +40,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
KyselyModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@@ -38,6 +49,9 @@ import { Module } from "@nestjs/common";
|
|||||||
SchedulesModule_2024_04_15,
|
SchedulesModule_2024_04_15,
|
||||||
EventTypesModule_2024_04_15,
|
EventTypesModule_2024_04_15,
|
||||||
EventTypesModule_2024_06_14,
|
EventTypesModule_2024_06_14,
|
||||||
|
StripeModule,
|
||||||
|
TeamsModule,
|
||||||
|
TeamsEventTypesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
@@ -47,6 +61,7 @@ import { Module } from "@nestjs/common";
|
|||||||
BookingsService_2024_08_13,
|
BookingsService_2024_08_13,
|
||||||
InputBookingsService_2024_08_13,
|
InputBookingsService_2024_08_13,
|
||||||
OutputBookingsService_2024_08_13,
|
OutputBookingsService_2024_08_13,
|
||||||
|
OutputBookingReferencesService_2024_08_13,
|
||||||
BookingsRepository_2024_08_13,
|
BookingsRepository_2024_08_13,
|
||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
BookingSeatRepository,
|
BookingSeatRepository,
|
||||||
@@ -57,6 +72,11 @@ import { Module } from "@nestjs/common";
|
|||||||
AppsRepository,
|
AppsRepository,
|
||||||
CalendarsRepository,
|
CalendarsRepository,
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
|
OrganizationsRepository,
|
||||||
|
ErrorsBookingsService_2024_08_13,
|
||||||
|
BookingReferencesService_2024_08_13,
|
||||||
|
BookingReferencesRepository_2024_08_13,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController_2024_08_13],
|
controllers: [BookingsController_2024_08_13],
|
||||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
|
||||||
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||||
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||||
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsService: BookingsService_2024_08_13,
|
private readonly bookingsService: BookingsService_2024_08_13,
|
||||||
private readonly usersService: UsersService
|
private readonly usersService: UsersService,
|
||||||
|
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -100,9 +106,13 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||||
|
|
||||||
Finally, there are 2 ways to book an event type:
|
Finally, there are 2 ways to book an event type belonging to an individual user:
|
||||||
1. Provide \`eventTypeId\` in the request body.
|
1. Provide \`eventTypeId\` in the request body.
|
||||||
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
||||||
|
|
||||||
|
And 2 ways to book and event type belonging to a team:
|
||||||
|
1. Provide \`eventTypeId\` in the request body.
|
||||||
|
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@@ -172,7 +182,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
): Promise<GetBookingsOutput_2024_08_13> {
|
): Promise<GetBookingsOutput_2024_08_13> {
|
||||||
const profile = this.usersService.getUserMainProfile(user);
|
const profile = this.usersService.getUserMainProfile(user);
|
||||||
|
|
||||||
const bookings = await this.bookingsService.getBookings(queryParams, {
|
const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
orgId: profile?.organizationId,
|
orgId: profile?.organizationId,
|
||||||
@@ -181,6 +191,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: bookings,
|
data: bookings,
|
||||||
|
pagination,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +290,8 @@ 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: "The provided authorization header refers to the owner of the booking.",
|
description:
|
||||||
|
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||||
})
|
})
|
||||||
async reassignBooking(
|
async reassignBooking(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -300,7 +312,8 @@ 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: "The provided authorization header refers to the owner of the booking.",
|
description:
|
||||||
|
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||||
})
|
})
|
||||||
async reassignBookingToUser(
|
async reassignBookingToUser(
|
||||||
@Param("bookingUid") bookingUid: string,
|
@Param("bookingUid") bookingUid: string,
|
||||||
@@ -382,4 +395,30 @@ export class BookingsController_2024_08_13 {
|
|||||||
data: calendarLinks,
|
data: calendarLinks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/:bookingUid/references")
|
||||||
|
@PlatformPlan("SCALE")
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@Permissions([BOOKING_READ])
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get 'Booking References' for a booking",
|
||||||
|
})
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getBookingReferences(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@Query() filter: BookingReferencesFilterInput_2024_08_13
|
||||||
|
): Promise<BookingReferencesOutput_2024_08_13> {
|
||||||
|
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
|
||||||
|
bookingUid,
|
||||||
|
userId,
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: bookingReferences,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let eventTypeId: number;
|
let eventTypeId: number;
|
||||||
let seatedEvent: EventType;
|
let seatedEvent: EventType;
|
||||||
let eventTypeWithBookingFields: EventType;
|
let eventTypeWithBookingFields: EventType;
|
||||||
|
let eventTypeWithBookingFielsCustom: EventType;
|
||||||
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||||
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||||
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||||
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
eventTypeWithBookingFielsCustom = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "test booking field types",
|
||||||
|
slug: "test-booking-field-types",
|
||||||
|
length: 30,
|
||||||
|
bookingFields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "name",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
variant: "fullName",
|
||||||
|
editable: "system",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "your_name",
|
||||||
|
variantsConfig: {
|
||||||
|
variants: {
|
||||||
|
fullName: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "fullName",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
firstAndLastName: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "firstName",
|
||||||
|
type: "text",
|
||||||
|
label: "",
|
||||||
|
required: true,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastName",
|
||||||
|
type: "text",
|
||||||
|
label: "",
|
||||||
|
required: false,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email",
|
||||||
|
type: "email",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "email_address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "location",
|
||||||
|
type: "radioInput",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "location",
|
||||||
|
getOptionsAt: "locations",
|
||||||
|
optionsInputs: {
|
||||||
|
phone: { type: "phone", required: true, placeholder: "" },
|
||||||
|
somewhereElse: { type: "text", required: true, placeholder: "" },
|
||||||
|
attendeeInPerson: { type: "address", required: true, placeholder: "" },
|
||||||
|
},
|
||||||
|
hideWhenJustOneOption: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-phone",
|
||||||
|
type: "phone",
|
||||||
|
label: "test-phone",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-address",
|
||||||
|
type: "address",
|
||||||
|
label: "test-address",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-text",
|
||||||
|
type: "text",
|
||||||
|
label: "test-text",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-number",
|
||||||
|
type: "number",
|
||||||
|
label: "test-number",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-textarea",
|
||||||
|
type: "textarea",
|
||||||
|
label: "test-textarea",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-boolean",
|
||||||
|
type: "boolean",
|
||||||
|
label: "test-boolean",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
labelAsSafeHtml: "<p>test-boolean</p>\n",
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-url",
|
||||||
|
type: "url",
|
||||||
|
label: "test-url",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
placeholder: "",
|
||||||
|
labelAsSafeHtml: "<p>test-url</p>\n",
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-multiemail",
|
||||||
|
type: "multiemail",
|
||||||
|
label: "test-multiemail",
|
||||||
|
hidden: false,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-multiselect",
|
||||||
|
type: "multiselect",
|
||||||
|
label: "test-multiselect",
|
||||||
|
hidden: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "apple",
|
||||||
|
value: "apple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "orange",
|
||||||
|
value: "orange",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-checkbox",
|
||||||
|
type: "checkbox",
|
||||||
|
label: "test-checkbox",
|
||||||
|
hidden: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "blue",
|
||||||
|
value: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "red",
|
||||||
|
value: "red",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-radio",
|
||||||
|
type: "radio",
|
||||||
|
label: "test-radio",
|
||||||
|
hidden: false,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "pineapple",
|
||||||
|
value: "pineapple",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: false,
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test-select",
|
||||||
|
type: "select",
|
||||||
|
label: "test-select",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "water",
|
||||||
|
value: "water",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "juice",
|
||||||
|
value: "juice",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "user",
|
||||||
|
required: true,
|
||||||
|
placeholder: "",
|
||||||
|
disableOnPrefill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
type: "text",
|
||||||
|
hidden: true,
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "what_is_this_meeting_about",
|
||||||
|
defaultPlaceholder: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notes",
|
||||||
|
type: "textarea",
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "additional_notes",
|
||||||
|
defaultPlaceholder: "share_additional_notes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guests",
|
||||||
|
type: "multiemail",
|
||||||
|
hidden: false,
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "additional_guests",
|
||||||
|
defaultPlaceholder: "email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rescheduleReason",
|
||||||
|
type: "textarea",
|
||||||
|
views: [{ id: "reschedule", label: "Reschedule View" }],
|
||||||
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
|
editable: "system-but-optional",
|
||||||
|
required: false,
|
||||||
|
defaultLabel: "reason_for_reschedule",
|
||||||
|
defaultPlaceholder: "reschedule_placeholder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
bookingWithSplitName = await bookingsRepositoryFixture.create({
|
bookingWithSplitName = await bookingsRepositoryFixture.create({
|
||||||
user: {
|
user: {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -543,6 +851,318 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Booking Field Type Validation", () => {
|
||||||
|
const basePayload = {
|
||||||
|
start: "2025-06-19T11:00:00.000Z",
|
||||||
|
attendee: {
|
||||||
|
name: "Charlie TypeTest",
|
||||||
|
email: "charlie.typetest@example.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "en",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-phone' (phone) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-phone": 12345,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-phone'. Expected type string (compatible with field type 'phone'), but received number."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-address' (address) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-address": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-address'. Expected type string (compatible with field type 'address'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-text' (text) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-text": 123,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-text'. Expected type string (compatible with field type 'text'), but received number."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-number' (number) is not a number", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-number": "123",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-number'. Expected type number (compatible with field type 'number'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-textarea' (textarea) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-textarea": { text: "invalid" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-textarea'. Expected type string (compatible with field type 'textarea'), but received object."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-boolean' (boolean) is not a boolean", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-boolean": "true",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-boolean'. Expected type boolean (compatible with field type 'boolean'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-url' (url) is not a string", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-url": 12345,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-url'. Expected type string (compatible with field type 'url'), but received number."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-multiemail' (multiemail) is not an array", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-multiemail": "not-an-array@example.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-multiemail'. Expected type array (compatible with field type 'multiemail'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-multiselect' (multiselect) is not an array", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-multiselect": "not-an-array",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-multiselect'. Expected type array (compatible with field type 'multiselect'), but received string."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-checkbox' (checkbox) is not an array", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-checkbox": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-checkbox'. Expected type array (compatible with field type 'checkbox'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-radio' (radio) is not a string or number", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-radio": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-radio'. Expected type string or number (compatible with field type 'radio'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject with 400 if 'test-select' (select) is not a string or number", async () => {
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
"test-select": true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
"Invalid type for booking field 'test-select'. Expected type string or number (compatible with field type 'select'), but received boolean."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-select field with invalid option", async () => {
|
||||||
|
const fieldName = "test-select";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: "INVALID_DRINK",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Invalid option 'INVALID_DRINK' for booking field '${fieldName}'. Allowed options are: water, juice.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-radio field with invalid option", async () => {
|
||||||
|
const fieldName = "test-radio";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: "INVALID_FRUIT_FOR_RADIO",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`Invalid option 'INVALID_FRUIT_FOR_RADIO' for booking field '${fieldName}'. Allowed options are: pineapple.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-multiselect field with an invalid option", async () => {
|
||||||
|
const fieldName = "test-multiselect";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: ["apple", "INVALID_FRUIT"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`One or more invalid options for booking field '${fieldName}'. Allowed options are: apple, orange.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for test-checkbox field with invalid options", async () => {
|
||||||
|
const fieldName = "test-checkbox";
|
||||||
|
const payload = {
|
||||||
|
...basePayload,
|
||||||
|
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
[fieldName]: ["blue", "INVALID_COLOR"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings`)
|
||||||
|
.send(payload)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toBe(
|
||||||
|
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
+4
-4
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-627
@@ -58,14 +58,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let user: User;
|
let user: User;
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
let seatedEventTypeId: number;
|
||||||
let recurringSeatedEventTypeId: number;
|
|
||||||
const maxRecurrenceCount = 3;
|
const maxRecurrenceCount = 3;
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
|
|
||||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
|
||||||
|
|
||||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||||
@@ -126,22 +123,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
);
|
);
|
||||||
seatedEventTypeId = seatedEvent.id;
|
seatedEventTypeId = seatedEvent.id;
|
||||||
|
|
||||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
|
||||||
seatsPerTimeSlot: 5,
|
|
||||||
seatsShowAttendees: true,
|
|
||||||
seatsShowAvailabilityCount: true,
|
|
||||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -321,127 +302,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should book a recurring event type with seats", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: "Mr Proper",
|
|
||||||
email: "mr_proper@gmail.com",
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
metadata: {
|
|
||||||
userId: "300",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/v2/bookings")
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(1);
|
|
||||||
expect(firstBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(1);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get a booking for an event type with seats", async () => {
|
it("should get a booking for an event type with seats", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
||||||
@@ -469,62 +329,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should get a booking for a recurring event type with seats", async () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(200)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
const expected = structuredClone(createdRecurringSeatedBooking);
|
|
||||||
for (const booking of expected) {
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete booking.seatUid;
|
|
||||||
}
|
|
||||||
expect(data).toEqual(expected);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(200)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
|
||||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
|
||||||
const expected = structuredClone(createdRecurringSeatedBooking[0]);
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete expected.seatUid;
|
|
||||||
expect(data).toEqual(expected);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get all seated bookings", async () => {
|
it("should get all seated bookings", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?sortCreated=asc")
|
.get("/v2/bookings?sortCreated=asc")
|
||||||
@@ -534,7 +338,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
const responseBody: GetBookingsOutput_2024_08_13 = 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.length).toEqual(3);
|
expect(responseBody.data.length).toEqual(1);
|
||||||
|
|
||||||
const seatedBooking = responseBody.data[0];
|
const seatedBooking = responseBody.data[0];
|
||||||
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
||||||
@@ -543,16 +347,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete seatedBookingExpected.seatUid;
|
delete seatedBookingExpected.seatUid;
|
||||||
expect(seatedBooking).toEqual(seatedBookingExpected);
|
expect(seatedBooking).toEqual(seatedBookingExpected);
|
||||||
|
|
||||||
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
|
|
||||||
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
|
|
||||||
for (const booking of recurringSeatedBookingsExpected) {
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete booking.seatUid;
|
|
||||||
}
|
|
||||||
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -682,424 +476,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring seated bookings creation", () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let organization: Team;
|
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
|
||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
|
||||||
let schedulesService: SchedulesService_2024_04_15;
|
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
|
||||||
let oAuthClient: PlatformOAuthClient;
|
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
|
||||||
|
|
||||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
|
||||||
let user: User;
|
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
|
||||||
let recurringSeatedEventTypeId: number;
|
|
||||||
const maxRecurrenceCount = 3;
|
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
|
|
||||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
|
||||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
|
||||||
|
|
||||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
|
||||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
|
||||||
const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`;
|
|
||||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await withApiAuth(
|
|
||||||
userEmail,
|
|
||||||
Test.createTestingModule({
|
|
||||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.overrideGuard(PermissionsGuard)
|
|
||||||
.useValue({
|
|
||||||
canActivate: () => true,
|
|
||||||
})
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
|
||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
|
||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({
|
|
||||||
name: `seated-bookings-organization-${randomString()}`,
|
|
||||||
});
|
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
|
||||||
email: userEmail,
|
|
||||||
platformOAuthClients: {
|
|
||||||
connect: {
|
|
||||||
id: oAuthClient.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
|
||||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
isDefault: true,
|
|
||||||
};
|
|
||||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
|
||||||
const seatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-event-type-${randomString()}`,
|
|
||||||
slug: seatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
seatedEventTypeId = seatedEvent.id;
|
|
||||||
|
|
||||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
|
||||||
bootstrap(app as NestExpressApplication);
|
|
||||||
|
|
||||||
await app.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
|
||||||
const data = {
|
|
||||||
logo: "logo-url",
|
|
||||||
name: "name",
|
|
||||||
redirectUris: ["http://localhost:5555"],
|
|
||||||
permissions: 32,
|
|
||||||
};
|
|
||||||
const secret = "secret";
|
|
||||||
|
|
||||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should book a recurring event type with seats for the first time", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
userId: "1000",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/v2/bookings")
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(1);
|
|
||||||
expect(firstBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(1);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should book a recurring event type with seats for the second time", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
userId: "2000",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post("/v2/bookings")
|
|
||||||
.send(body)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(201)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(2);
|
|
||||||
const firstAttendee = firstBooking.attendees.find(
|
|
||||||
(attendee) => attendee.name === nameAttendeeOne
|
|
||||||
);
|
|
||||||
expect(firstAttendee).toEqual({
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: createdRecurringSeatedBooking[0].seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
|
||||||
});
|
|
||||||
const secondAttendee = firstBooking.attendees.find(
|
|
||||||
(attendee) => attendee.name === nameAttendeeTwo
|
|
||||||
);
|
|
||||||
expect(secondAttendee).toEqual({
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(2);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: createdRecurringSeatedBooking[1].seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees[1]).toEqual({
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function responseDataIsCreateRecurringSeatedBooking(
|
|
||||||
data: any
|
|
||||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
|
||||||
return Array.isArray(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
|
||||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseDataIsGetRecurringSeatedBooking(
|
|
||||||
data: any
|
|
||||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
|
||||||
return Array.isArray(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
|
||||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -39,7 +39,10 @@ import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
|||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
describe("Team bookings", () => {
|
describe("Team bookings", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
const organizationSlug = `team-bookings-organization-${randomString()}`;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
|
const team1Slug = `team-bookings-team1-${randomString()}`;
|
||||||
|
const team2Slug = `team-bookings-team2-${randomString()}`;
|
||||||
let team1: Team;
|
let team1: Team;
|
||||||
let team2: Team;
|
let team2: Team;
|
||||||
|
|
||||||
@@ -95,12 +98,15 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
organization = await organizationsRepositoryFixture.create({
|
organization = await organizationsRepositoryFixture.create({
|
||||||
name: `team-bookings-organization-${randomString()}`,
|
name: organizationSlug,
|
||||||
|
slug: organizationSlug,
|
||||||
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
team1 = await teamRepositoryFixture.create({
|
team1 = await teamRepositoryFixture.create({
|
||||||
name: `team-bookings-team1-${randomString()}`,
|
name: team1Slug,
|
||||||
|
slug: team1Slug,
|
||||||
isOrganization: false,
|
isOrganization: false,
|
||||||
parent: { connect: { id: organization.id } },
|
parent: { connect: { id: organization.id } },
|
||||||
createdByOAuthClient: {
|
createdByOAuthClient: {
|
||||||
@@ -111,7 +117,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
team2 = await teamRepositoryFixture.create({
|
team2 = await teamRepositoryFixture.create({
|
||||||
name: `team-bookings-team2-${randomString()}`,
|
name: team2Slug,
|
||||||
|
slug: team2Slug,
|
||||||
isOrganization: false,
|
isOrganization: false,
|
||||||
parent: { connect: { id: organization.id } },
|
parent: { connect: { id: organization.id } },
|
||||||
createdByOAuthClient: {
|
createdByOAuthClient: {
|
||||||
@@ -668,6 +675,84 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("book using teamSlug, eventTypeSlug and organizationSlug", () => {
|
||||||
|
it("should not be able to book if missing organizationSlug", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||||
|
teamSlug: team1Slug,
|
||||||
|
eventTypeSlug: team1EventTypeSlug,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should book using teamSlug and eventTypeSlug and organizationSlug", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||||
|
teamSlug: team1Slug,
|
||||||
|
eventTypeSlug: team1EventTypeSlug,
|
||||||
|
organizationSlug: organizationSlug,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
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();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts.length).toEqual(1);
|
||||||
|
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(60);
|
||||||
|
expect(data.eventTypeId).toEqual(team1EventTypeId);
|
||||||
|
expect(data.attendees.length).toEqual(1);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
email: body.attendee.email,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||||
|
expect(data.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { EventType, User } from "@prisma/client";
|
import { EventType, User, Workflow } from "@prisma/client";
|
||||||
import { advanceTo, clear } from "jest-date-mock";
|
import { advanceTo, clear } from "jest-date-mock";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
|
|||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
|
||||||
|
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
@@ -32,8 +34,10 @@ import {
|
|||||||
VERSION_2024_08_13,
|
VERSION_2024_08_13,
|
||||||
X_CAL_CLIENT_ID,
|
X_CAL_CLIENT_ID,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
|
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||||
import {
|
import {
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
|
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||||
GetBookingOutput_2024_08_13,
|
GetBookingOutput_2024_08_13,
|
||||||
GetBookingsOutput_2024_08_13,
|
GetBookingsOutput_2024_08_13,
|
||||||
GetSeatedBookingOutput_2024_08_13,
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
@@ -59,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let schedulesService: SchedulesService_2024_04_15;
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
|
||||||
|
let workflowRepositoryFixture: WorkflowRepositoryFixture;
|
||||||
let oAuthClient: PlatformOAuthClient;
|
let oAuthClient: PlatformOAuthClient;
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
@@ -67,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
let eventTypeId: number;
|
let eventTypeId: number;
|
||||||
let eventType: EventType;
|
let eventType: EventType;
|
||||||
|
let eventTypeWithAttendeeSmsReminder: EventType;
|
||||||
|
let workflow: Workflow;
|
||||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||||
let recurringEventTypeId: number;
|
let recurringEventTypeId: number;
|
||||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||||
@@ -96,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
|
||||||
|
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({
|
organization = await teamRepositoryFixture.create({
|
||||||
name: `user-bookings-organization-${randomString()}`,
|
name: `user-bookings-organization-${randomString()}`,
|
||||||
@@ -176,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
rating: 10,
|
rating: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
workflow = await workflowRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: "BEFORE_EVENT",
|
||||||
|
time: 24,
|
||||||
|
timeUnit: "HOUR",
|
||||||
|
position: 0,
|
||||||
|
isActiveOnAll: false,
|
||||||
|
name: "Attendee SMS Reminder",
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
stepNumber: 1,
|
||||||
|
action: "SMS_ATTENDEE",
|
||||||
|
sendTo: null,
|
||||||
|
reminderBody:
|
||||||
|
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
|
||||||
|
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
|
||||||
|
template: "REMINDER",
|
||||||
|
numberRequired: true,
|
||||||
|
sender: "Cal",
|
||||||
|
numberVerificationPending: false,
|
||||||
|
includeCalendarEvent: false,
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "event with attendee sms reminder",
|
||||||
|
slug: "event-with-attendee-sms-reminder",
|
||||||
|
length: 15,
|
||||||
|
bookingFields: [
|
||||||
|
{
|
||||||
|
name: "smsReminderNumber",
|
||||||
|
type: "phone",
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: String(workflow.id),
|
||||||
|
type: "workflow",
|
||||||
|
label: "Workflow",
|
||||||
|
editUrl: `/workflows/${workflow.id}`,
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: "system",
|
||||||
|
required: true,
|
||||||
|
defaultLabel: "number_text_notifications",
|
||||||
|
defaultPlaceholder: "enter_phone_number",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
disableStandardEmails: {
|
||||||
|
all: {
|
||||||
|
attendee: true,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
confirmation: {
|
||||||
|
host: true,
|
||||||
|
attendee: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflows: {
|
||||||
|
create: {
|
||||||
|
workflow: {
|
||||||
|
connect: {
|
||||||
|
id: workflow.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -582,7 +671,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(data.rating).toEqual(bookingInThePast.rating);
|
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -641,7 +730,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1667,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
describe("book by username and event type slug", () => {
|
describe("book by username and event type slug", () => {
|
||||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
|
||||||
const body = {
|
const body = {
|
||||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
attendee: {
|
attendee: {
|
||||||
@@ -1676,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
language: "it",
|
language: "it",
|
||||||
},
|
},
|
||||||
location: googleMeetUrl,
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
customField: "customValue",
|
customField: "customValue",
|
||||||
},
|
},
|
||||||
@@ -1692,11 +1780,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(
|
expect(response.body.error.message.includes(FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE)).toBe(
|
||||||
response.body.error.message.includes(
|
true
|
||||||
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
|
);
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a booking by username and event type slug", async () => {
|
it("should create a booking by username and event type slug", async () => {
|
||||||
@@ -2228,6 +2314,91 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("create booking with attendee sms reminder", () => {
|
||||||
|
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@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(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toEqual(
|
||||||
|
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be able create a booking if attendee sms reminder is provided", async () => {
|
||||||
|
const phoneNumber = "+37122222222";
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
phoneNumber,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts.length).toEqual(1);
|
||||||
|
expect(data.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(15);
|
||||||
|
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
|
||||||
|
expect(data.attendees.length).toEqual(1);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
email: body.attendee.email,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
phoneNumber: body.attendee.phoneNumber,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.bookingFieldsResponses).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
email: body.attendee.email,
|
||||||
|
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||||
|
smsReminderNumber: body.attendee.phoneNumber,
|
||||||
|
});
|
||||||
|
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
|
||||||
|
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
|
||||||
|
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
|
||||||
|
expect(dbWorkflowReminder?.method).toEqual("SMS");
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid response data");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||||
return (
|
return (
|
||||||
!Array.isArray(data) &&
|
!Array.isArray(data) &&
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class BookingUidGuard implements CanActivate {
|
|||||||
const bookingUid = request.params.bookingUid;
|
const bookingUid = request.params.bookingUid;
|
||||||
|
|
||||||
if (!bookingUid) {
|
if (!bookingUid) {
|
||||||
throw new BadRequestException("Booking UID missing in the request path");
|
throw new BadRequestException("BookingUidGuard - Booking UID missing in the request path");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
export const BookingReferences = [
|
||||||
|
"google_calendar",
|
||||||
|
"office365_calendar",
|
||||||
|
"daily_video",
|
||||||
|
"google_video",
|
||||||
|
"office365_video",
|
||||||
|
"zoom_video",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class BookingReferencesFilterInput_2024_08_13 {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Filter booking references by type",
|
||||||
|
required: false,
|
||||||
|
enum: BookingReferences,
|
||||||
|
example: "google_calendar",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(BookingReferences)
|
||||||
|
type?: (typeof BookingReferences)[number];
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEnum, IsNumber, IsString } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class BookingReference {
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The type of the booking reference",
|
||||||
|
})
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The event uid of the booking",
|
||||||
|
})
|
||||||
|
eventUid!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The id of the calendar the event is created in",
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
destinationCalendarId!: string | null;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The id of the booking reference",
|
||||||
|
})
|
||||||
|
id!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingReferencesOutput_2024_08_13 {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "The status of the request, always 'success' for successful responses",
|
||||||
|
example: SUCCESS_STATUS,
|
||||||
|
})
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Booking References",
|
||||||
|
type: [BookingReference],
|
||||||
|
})
|
||||||
|
data!: BookingReference[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
|
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingReferencesService_2024_08_13 {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
|
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
|
||||||
|
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBookingReferences(
|
||||||
|
bookingUid: string,
|
||||||
|
userId: number,
|
||||||
|
filter?: BookingReferencesFilterInput_2024_08_13
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.user?.id !== userId) {
|
||||||
|
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
|
||||||
|
|
||||||
|
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrgBookingReferences(bookingUid: string, filter?: BookingReferencesFilterInput_2024_08_13) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
|
||||||
|
|
||||||
|
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
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 { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||||
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
import { 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 { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -53,13 +61,17 @@ type CreatedBooking = {
|
|||||||
start: string;
|
start: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventTypeBookingFieldSchema = z.object({
|
const eventTypeBookingFieldSchema = z
|
||||||
name: z.string(),
|
.object({
|
||||||
required: z.boolean(),
|
name: z.string(),
|
||||||
editable: z.string(),
|
required: z.boolean(),
|
||||||
});
|
editable: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
options: z.array(z.object({ value: z.string() })).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||||
|
|
||||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||||
|
|
||||||
@@ -73,28 +85,34 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly bookingSeatRepository: BookingSeatRepository,
|
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly kyselyReadService: KyselyReadService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly platformBookingsService: PlatformBookingsService,
|
private readonly platformBookingsService: PlatformBookingsService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
|
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||||
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly teamsRepository: TeamsRepository,
|
||||||
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||||
|
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createBooking(request: Request, body: CreateBookingInput) {
|
async createBooking(request: Request, body: CreateBookingInput) {
|
||||||
|
let bookingTeamEventType = false;
|
||||||
try {
|
try {
|
||||||
const eventType = await this.getBookedEventType(body);
|
const eventType = await this.getBookedEventType(body);
|
||||||
|
if (eventType?.team) {
|
||||||
|
bookingTeamEventType = true;
|
||||||
|
}
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||||
throw new NotFoundException(
|
}
|
||||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
|
||||||
);
|
if (eventType.schedulingType === "MANAGED") {
|
||||||
}
|
throw new BadRequestException(
|
||||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
`Event type with id=${eventType.id} is the parent managed event type that can't be booked. You have to provide the child event type id aka id of event type that has been assigned to one of the users.`
|
||||||
throw new NotFoundException(
|
);
|
||||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.eventTypeId = eventType.id;
|
body.eventTypeId = eventType.id;
|
||||||
@@ -120,12 +138,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
return await this.createRegularBooking(request, body, eventType);
|
return await this.createRegularBooking(request, body, eventType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
this.errorsBookingsService.handleBookingError(error, bookingTeamEventType);
|
||||||
if (error.message === "no_available_users_found_error") {
|
|
||||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,37 +154,227 @@ export class BookingsService_2024_08_13 {
|
|||||||
user.id,
|
user.id,
|
||||||
body.eventTypeSlug
|
body.eventTypeSlug
|
||||||
);
|
);
|
||||||
|
} else if (body.teamSlug && body.eventTypeSlug) {
|
||||||
|
const team = await this.getBookedEventTypeTeam(body.teamSlug, body.organizationSlug);
|
||||||
|
if (!team) {
|
||||||
|
throw new NotFoundException(`Team with slug ${body.teamSlug} not found`);
|
||||||
|
}
|
||||||
|
return await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlugWithOwnerAndTeam(
|
||||||
|
team.id,
|
||||||
|
body.eventTypeSlug
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBookedEventTypeTeam(teamSlug: string, organizationSlug: string | undefined) {
|
||||||
|
if (!organizationSlug) {
|
||||||
|
return await this.teamsRepository.findTeamBySlug(teamSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = await this.organizationsRepository.findOrgBySlug(organizationSlug);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`slots-input.service.ts: Organization with slug ${organizationSlug} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.organizationsTeamsRepository.findOrgTeamBySlug(organization.id, teamSlug);
|
||||||
|
}
|
||||||
|
|
||||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||||
const bookingFields = body.bookingFieldsResponses;
|
const bookingFieldsResponses: Record<string, unknown> = {
|
||||||
if (!bookingFields || !eventType || !eventType.bookingFields) {
|
...body.bookingFieldsResponses,
|
||||||
|
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||||
|
smsReminderNumber: body.attendee.phoneNumber,
|
||||||
|
};
|
||||||
|
if (!eventType?.bookingFields) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
|
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
|
||||||
|
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
|
||||||
const eventTypeBookingFields = eventTypeBookingFieldsSchema
|
const eventTypeBookingFields = eventTypeBookingFieldsSchema
|
||||||
.parse(eventType.bookingFields)
|
.parse(eventType.bookingFields)
|
||||||
.filter((field) => !field.editable.startsWith("system"));
|
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
|
||||||
|
|
||||||
for (const field of eventTypeBookingFields) {
|
if (!eventTypeBookingFields.length) {
|
||||||
if (field.required && !(field.name in bookingFields)) {
|
return true;
|
||||||
throw new BadRequestException(`
|
}
|
||||||
Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses.
|
|
||||||
You can fetch the event type with ID ${eventType.id} to see the required fields.`);
|
for (const eventTypeBookingField of eventTypeBookingFields) {
|
||||||
|
if (
|
||||||
|
eventTypeBookingField.required &&
|
||||||
|
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
|
||||||
|
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
eventTypeBookingField.name === "attendeePhoneNumber" ||
|
||||||
|
eventTypeBookingField.name === "smsReminderNumber"
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Missing required booking field response: ${eventTypeBookingField.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingFieldResponseValue = bookingFieldsResponses[eventTypeBookingField.name];
|
||||||
|
if (bookingFieldResponseValue !== undefined) {
|
||||||
|
const bookingFieldResponseValueType = typeof bookingFieldResponseValue;
|
||||||
|
let expectedBookingFieldResponseValueType = "";
|
||||||
|
let isValidType = false;
|
||||||
|
const eventTypeBookingFieldType = eventTypeBookingField.type;
|
||||||
|
|
||||||
|
switch (eventTypeBookingFieldType) {
|
||||||
|
case "phone":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "address":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "text":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
expectedBookingFieldResponseValueType = "number";
|
||||||
|
isValidType = bookingFieldResponseValueType === "number";
|
||||||
|
break;
|
||||||
|
case "textarea":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
case "multiemail":
|
||||||
|
expectedBookingFieldResponseValueType = "array";
|
||||||
|
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||||
|
break;
|
||||||
|
case "boolean":
|
||||||
|
expectedBookingFieldResponseValueType = "boolean";
|
||||||
|
isValidType = bookingFieldResponseValueType === "boolean";
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
expectedBookingFieldResponseValueType = "string or number";
|
||||||
|
isValidType =
|
||||||
|
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValue = bookingFieldResponseValue as string | number;
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid option '${submittedValue}' for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "multiselect":
|
||||||
|
expectedBookingFieldResponseValueType = "array";
|
||||||
|
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`One or more invalid options for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "checkbox":
|
||||||
|
expectedBookingFieldResponseValueType = "array";
|
||||||
|
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`One or more invalid options for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "radio":
|
||||||
|
expectedBookingFieldResponseValueType = "string or number";
|
||||||
|
isValidType =
|
||||||
|
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||||
|
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||||
|
const submittedValue = bookingFieldResponseValue as string | number;
|
||||||
|
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||||
|
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid option '${submittedValue}' for booking field '${
|
||||||
|
eventTypeBookingField.name
|
||||||
|
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "url":
|
||||||
|
expectedBookingFieldResponseValueType = "string";
|
||||||
|
isValidType = bookingFieldResponseValueType === "string";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// note(Lauris): by default pass the field if we have a missing "case" in the switch
|
||||||
|
isValidType = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid type for booking field '${eventTypeBookingField.name}'. Expected type ${expectedBookingFieldResponseValueType} (compatible with field type '${eventTypeBookingFieldType}'), but received ${bookingFieldResponseValueType}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isValidSingleOptionValue(
|
||||||
|
bookingFieldResponseValue: string | number,
|
||||||
|
eventTypeBookingFieldOptions: string[]
|
||||||
|
): boolean {
|
||||||
|
if (eventTypeBookingFieldOptions.length === 0) {
|
||||||
|
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return eventTypeBookingFieldOptions.some((val) => val === String(bookingFieldResponseValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
private areValidMultipleOptionValues(
|
||||||
|
bookingFieldResponseValues: (string | number)[],
|
||||||
|
eventTypeBookingFieldOptions: string[]
|
||||||
|
): boolean {
|
||||||
|
if (eventTypeBookingFieldOptions.length === 0) {
|
||||||
|
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return bookingFieldResponseValues.every((submittedVal) =>
|
||||||
|
eventTypeBookingFieldOptions.some((allowedVal) => allowedVal === String(submittedVal))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async createInstantBooking(
|
async createInstantBooking(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: CreateInstantBookingInput_2024_08_13,
|
body: CreateInstantBookingInput_2024_08_13,
|
||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
|
if (!eventType.team?.id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Instant bookings are only supported for team event types, not individual user event types."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 handleInstantMeeting(bookingRequest);
|
||||||
|
|
||||||
@@ -185,7 +388,11 @@ export class BookingsService_2024_08_13 {
|
|||||||
return this.outputService.getOutputBooking(databaseBooking);
|
return this.outputService.getOutputBooking(databaseBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
async createRecurringBooking(
|
||||||
|
request: Request,
|
||||||
|
body: CreateRecurringBookingInput_2024_08_13,
|
||||||
|
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 handleNewRecurringBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
@@ -202,7 +409,11 @@ export class BookingsService_2024_08_13 {
|
|||||||
return this.outputService.getOutputRecurringBookings(ids);
|
return this.outputService.getOutputRecurringBookings(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
async createRecurringSeatedBooking(
|
||||||
|
request: Request,
|
||||||
|
body: CreateRecurringBookingInput_2024_08_13,
|
||||||
|
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 handleNewRecurringBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
@@ -219,7 +430,11 @@ export class BookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
async createRegularBooking(
|
||||||
|
request: Request,
|
||||||
|
body: CreateBookingInput_2024_08_13,
|
||||||
|
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 handleNewBooking({
|
||||||
bookingData: bookingRequest.body,
|
bookingData: bookingRequest.body,
|
||||||
@@ -244,31 +459,43 @@ export class BookingsService_2024_08_13 {
|
|||||||
return this.outputService.getOutputBooking(databaseBooking);
|
return this.outputService.getOutputBooking(databaseBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
async createSeatedBooking(
|
||||||
|
request: Request,
|
||||||
|
body: CreateBookingInput_2024_08_13,
|
||||||
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
|
) {
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
const booking = await handleNewBooking({
|
try {
|
||||||
bookingData: bookingRequest.body,
|
const booking = await handleNewBooking({
|
||||||
userId: bookingRequest.userId,
|
bookingData: bookingRequest.body,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
});
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
throw new Error("Booking missing uid");
|
throw new Error("Booking missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseBooking =
|
||||||
|
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "booking_seats_full_error") {
|
||||||
|
throw new ConflictException("No more seats left at this seated booking.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
|
|
||||||
booking.uid
|
|
||||||
);
|
|
||||||
if (!databaseBooking) {
|
|
||||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBooking(uid: string) {
|
async getBooking(uid: string) {
|
||||||
@@ -312,10 +539,13 @@ export class BookingsService_2024_08_13 {
|
|||||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
const skip = Math.abs(queryParams?.skip ?? 0);
|
||||||
|
const take = Math.abs(queryParams?.take ?? 100);
|
||||||
|
|
||||||
|
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
|
||||||
bookingListingByStatus: queryParams.status || [],
|
bookingListingByStatus: queryParams.status || [],
|
||||||
skip: queryParams.skip ?? 0,
|
skip,
|
||||||
take: queryParams.take ?? 100,
|
take,
|
||||||
filters: {
|
filters: {
|
||||||
...this.inputService.transformGetBookingsFilters(queryParams),
|
...this.inputService.transformGetBookingsFilters(queryParams),
|
||||||
...(userIds?.length ? { userIds } : {}),
|
...(userIds?.length ? { userIds } : {}),
|
||||||
@@ -323,6 +553,7 @@ export class BookingsService_2024_08_13 {
|
|||||||
ctx: {
|
ctx: {
|
||||||
user,
|
user,
|
||||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
kysely: this.kyselyReadService.kysely,
|
||||||
},
|
},
|
||||||
sort: this.inputService.transformGetBookingsSort(queryParams),
|
sort: this.inputService.transformGetBookingsSort(queryParams),
|
||||||
});
|
});
|
||||||
@@ -367,7 +598,12 @@ export class BookingsService_2024_08_13 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return formattedBookings;
|
const pagination = getPagination({ skip, take, totalCount: fetchedBookings.totalCount });
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookings: formattedBookings,
|
||||||
|
pagination,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
||||||
@@ -507,6 +743,20 @@ export class BookingsService_2024_08_13 {
|
|||||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
|
||||||
|
if (!bookingBefore) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowUtc = DateTime.utc();
|
||||||
|
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||||
|
|
||||||
|
if (nowUtc < bookingStartTimeUtc) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const platformClientParams = bookingBefore?.eventTypeId
|
const platformClientParams = bookingBefore?.eventTypeId
|
||||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -579,13 +829,24 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||||
|
|
||||||
await roundRobinReassignment({
|
try {
|
||||||
bookingId: booking.id,
|
await roundRobinReassignment({
|
||||||
orgId: profile?.organizationId || null,
|
bookingId: booking.id,
|
||||||
emailsEnabled,
|
orgId: profile?.organizationId || null,
|
||||||
platformClientParams,
|
emailsEnabled,
|
||||||
reassignedById: requestUser.id,
|
platformClientParams,
|
||||||
});
|
reassignedById: requestUser.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "no_available_users_found_error") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Can't reassign the booking because no other host is available at that time."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
if (!reassigned) {
|
if (!reassigned) {
|
||||||
@@ -619,17 +880,28 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const profile = this.usersService.getUserMainProfile(user);
|
const profile = this.usersService.getUserMainProfile(user);
|
||||||
|
|
||||||
const reassigned = await roundRobinManualReassignment({
|
try {
|
||||||
bookingId: booking.id,
|
const reassigned = await roundRobinManualReassignment({
|
||||||
newUserId,
|
bookingId: booking.id,
|
||||||
orgId: profile?.organizationId || null,
|
newUserId,
|
||||||
reassignReason: body.reason,
|
orgId: profile?.organizationId || null,
|
||||||
reassignedById,
|
reassignReason: body.reason,
|
||||||
emailsEnabled,
|
reassignedById,
|
||||||
platformClientParams,
|
emailsEnabled,
|
||||||
});
|
platformClientParams,
|
||||||
|
});
|
||||||
|
|
||||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "invalid_round_robin_host") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`User with id=${newUserId} is not a valid Round Robin host - the user to which you reassign this booking must be one of the booking hosts. Fetch the booking using following endpoint and select id of one of the hosts: https://cal.com/docs/api-reference/v2/bookings/get-a-booking`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CreateBookingInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ErrorsBookingsService_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("ErrorsBookingsService_2024_08_13");
|
||||||
|
|
||||||
|
handleEventTypeToBeBookedNotFound(body: CreateBookingInput): never {
|
||||||
|
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookingError(error: unknown, bookingTeamEventType: boolean): never {
|
||||||
|
const hostsUnavaile = "One of the hosts either already has booking at this time or is not available";
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "no_available_users_found_error") {
|
||||||
|
if (bookingTeamEventType) {
|
||||||
|
throw new BadRequestException(hostsUnavaile);
|
||||||
|
}
|
||||||
|
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||||
|
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The event type can't be booked at the "start" time provided. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||||
|
);
|
||||||
|
} else if (error.message === "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") {
|
||||||
|
throw new BadRequestException(hostsUnavaile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
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 { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import {
|
||||||
|
eventTypeBookingFieldsSchema,
|
||||||
|
EventTypeWithOwnerAndTeam,
|
||||||
|
} from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
bookingResponsesSchema,
|
bookingResponsesSchema,
|
||||||
seatedBookingDataSchema,
|
seatedBookingDataSchema,
|
||||||
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
||||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||||
|
|
||||||
|
const needsSmsReminderNumber = eventType.bookingFields
|
||||||
|
? eventTypeBookingFieldsSchema
|
||||||
|
.parse(eventType.bookingFields)
|
||||||
|
.find((field) => field.name === "smsReminderNumber")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start: startTime.toISO(),
|
start: startTime.toISO(),
|
||||||
end: endTime.toISO(),
|
end: endTime.toISO(),
|
||||||
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
name: inputBooking.attendee.name,
|
name: inputBooking.attendee.name,
|
||||||
email: attendeeEmail ?? "",
|
email: attendeeEmail ?? "",
|
||||||
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
||||||
|
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
|
||||||
guests,
|
guests,
|
||||||
location,
|
location,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
interface IBookingReference {
|
||||||
|
type: string;
|
||||||
|
uid: string;
|
||||||
|
id: number;
|
||||||
|
externalCalendarId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutputBookingReferencesService_2024_08_13 {
|
||||||
|
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
|
||||||
|
return bookingReferences.map((bookingReference) => ({
|
||||||
|
type: bookingReference.type,
|
||||||
|
eventUid: bookingReference.uid,
|
||||||
|
id: bookingReference.id,
|
||||||
|
destinationCalendarId: bookingReference?.externalCalendarId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
RecurringBookingOutput_2024_08_13,
|
RecurringBookingOutput_2024_08_13,
|
||||||
SeatedAttendee,
|
SeatedAttendee,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export const bookingResponsesSchema = z
|
export const bookingResponsesSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
|
|
||||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
|
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect?redir=https://cal.com&isDryRun=false`)
|
||||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
.set("Origin", CLIENT_REDIRECT_URI)
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
|||||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
|
ParseBoolPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
@@ -50,7 +52,7 @@ import {
|
|||||||
APPLE_CALENDAR,
|
APPLE_CALENDAR,
|
||||||
CREDENTIAL_CALENDARS,
|
CREDENTIAL_CALENDARS,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/v2/calendars",
|
path: "/v2/calendars",
|
||||||
@@ -134,6 +136,7 @@ export class CalendarsController {
|
|||||||
name: "calendar",
|
name: "calendar",
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Get("/:calendar/connect")
|
@Get("/:calendar/connect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -148,13 +151,14 @@ export class CalendarsController {
|
|||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@Query("redir") redir?: string | null
|
@Query("redir") redir?: string | null,
|
||||||
|
@Query("isDryRun", new ParseBoolPipe({ optional: true })) isDryRun?: boolean
|
||||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||||
switch (calendar) {
|
switch (calendar) {
|
||||||
case OFFICE_365_CALENDAR:
|
case OFFICE_365_CALENDAR:
|
||||||
return await this.outlookService.connect(authorization, req, redir ?? "");
|
return await this.outlookService.connect(authorization, req, redir ?? "", isDryRun);
|
||||||
case GOOGLE_CALENDAR:
|
case GOOGLE_CALENDAR:
|
||||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
|
return await this.googleCalendarService.connect(authorization, req, redir ?? "", isDryRun);
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Invalid calendar type, available calendars are: ",
|
"Invalid calendar type, available calendars are: ",
|
||||||
@@ -171,7 +175,7 @@ export class CalendarsController {
|
|||||||
@Get("/:calendar/save")
|
@Get("/:calendar/save")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
|
@ApiOperation({ summary: "Save Google or Outlook calendar credentials" })
|
||||||
async save(
|
async save(
|
||||||
@Query("state") state: string,
|
@Query("state") state: string,
|
||||||
@Query("code") code: string,
|
@Query("code") code: string,
|
||||||
@@ -179,18 +183,30 @@ export class CalendarsController {
|
|||||||
): Promise<{ url: string }> {
|
): Promise<{ url: string }> {
|
||||||
// state params contains our user access token
|
// state params contains our user access token
|
||||||
const stateParams = new URLSearchParams(state);
|
const stateParams = new URLSearchParams(state);
|
||||||
const { accessToken, origin, redir } = z
|
const { accessToken, origin, redir, isDryRun } = z
|
||||||
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
|
.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
origin: z.string(),
|
||||||
|
redir: z.string().nullish().optional(),
|
||||||
|
isDryRun: z.string().nullish().optional(),
|
||||||
|
})
|
||||||
.parse({
|
.parse({
|
||||||
accessToken: stateParams.get("accessToken"),
|
accessToken: stateParams.get("accessToken"),
|
||||||
origin: stateParams.get("origin"),
|
origin: stateParams.get("origin"),
|
||||||
redir: stateParams.get("redir"),
|
redir: stateParams.get("redir"),
|
||||||
|
isDryRun: stateParams.get("isDryRun"),
|
||||||
});
|
});
|
||||||
switch (calendar) {
|
switch (calendar) {
|
||||||
case OFFICE_365_CALENDAR:
|
case OFFICE_365_CALENDAR:
|
||||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
|
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
|
||||||
case GOOGLE_CALENDAR:
|
case GOOGLE_CALENDAR:
|
||||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
|
return await this.googleCalendarService.save(
|
||||||
|
code,
|
||||||
|
accessToken,
|
||||||
|
origin,
|
||||||
|
redir ?? "",
|
||||||
|
isDryRun === "true"
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Invalid calendar type, available calendars are: ",
|
"Invalid calendar type, available calendars are: ",
|
||||||
@@ -207,11 +223,11 @@ export class CalendarsController {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Post("/:calendar/credentials")
|
@Post("/:calendar/credentials")
|
||||||
@ApiOperation({ summary: "Sync credentials" })
|
@ApiOperation({ summary: "Save Apple calendar credentials" })
|
||||||
async syncCredentials(
|
async syncCredentials(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@Body() body: { username: string; password: string }
|
@Body() body: CreateCalendarCredentialsInput
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const { username, password } = body;
|
const { username, password } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||||
if (username.length <= 1 || password.length <= 1)
|
if (!username || !password || username.length <= 1 || password.length <= 1) {
|
||||||
throw new BadRequestException(`Username or password cannot be empty`);
|
throw new BadRequestException(`Username or password cannot be empty`);
|
||||||
|
}
|
||||||
|
|
||||||
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
||||||
APPLE_CALENDAR_TYPE,
|
APPLE_CALENDAR_TYPE,
|
||||||
@@ -118,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
appId: APPLE_CALENDAR_ID,
|
appId: APPLE_CALENDAR_ID,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
delegationCredentialId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dav = new CalendarService({
|
const dav = new CalendarService({
|
||||||
|
|||||||
@@ -39,31 +39,38 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
|||||||
async connect(
|
async connect(
|
||||||
authorization: string,
|
authorization: string,
|
||||||
req: Request,
|
req: Request,
|
||||||
redir?: string
|
redir?: string,
|
||||||
|
isDryRun?: boolean
|
||||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||||
const accessToken = authorization.replace("Bearer ", "");
|
const accessToken = authorization.replace("Bearer ", "");
|
||||||
const origin = req.get("origin") ?? req.get("host");
|
const origin = req.get("origin") ?? req.get("host");
|
||||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
|
||||||
|
|
||||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
async save(
|
||||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
code: string,
|
||||||
|
accessToken: string,
|
||||||
|
origin: string,
|
||||||
|
redir?: string,
|
||||||
|
isDryRun?: boolean
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
return await this.checkIfCalendarConnected(userId);
|
return await this.checkIfCalendarConnected(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||||
|
|
||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
access_type: "offline",
|
access_type: "offline",
|
||||||
scope: CALENDAR_SCOPES,
|
scope: CALENDAR_SCOPES,
|
||||||
prompt: "consent",
|
prompt: "consent",
|
||||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return authUrl;
|
return authUrl;
|
||||||
@@ -115,7 +122,8 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
|||||||
code: string,
|
code: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
origin: string,
|
origin: string,
|
||||||
redir?: string
|
redir?: string,
|
||||||
|
isDryRun?: boolean
|
||||||
) {
|
) {
|
||||||
// User chose not to authorize your app or didn't authorize your app
|
// User chose not to authorize your app or didn't authorize your app
|
||||||
// redirect directly without oauth code
|
// redirect directly without oauth code
|
||||||
@@ -123,6 +131,11 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
|||||||
return { url: redir || origin };
|
return { url: redir || origin };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if isDryRun is true we know its a dry run so we just redirect straight away
|
||||||
|
if (isDryRun) {
|
||||||
|
return { url: redir || origin };
|
||||||
|
}
|
||||||
|
|
||||||
const parsedCode = z.string().parse(code);
|
const parsedCode = z.string().parse(code);
|
||||||
|
|
||||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
|||||||
teamId: null,
|
teamId: null,
|
||||||
appId: ICS_CALENDAR,
|
appId: ICS_CALENDAR,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
delegationCredentialId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -33,24 +33,31 @@ export class OutlookService implements OAuthCalendarApp {
|
|||||||
async connect(
|
async connect(
|
||||||
authorization: string,
|
authorization: string,
|
||||||
req: Request,
|
req: Request,
|
||||||
redir?: string
|
redir?: string,
|
||||||
|
isDryRun?: boolean
|
||||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||||
const accessToken = authorization.replace("Bearer ", "");
|
const accessToken = authorization.replace("Bearer ", "");
|
||||||
const origin = req.get("origin") ?? req.get("host");
|
const origin = req.get("origin") ?? req.get("host");
|
||||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
|
||||||
|
|
||||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
async save(
|
||||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
code: string,
|
||||||
|
accessToken: string,
|
||||||
|
origin: string,
|
||||||
|
redir?: string,
|
||||||
|
isDryRun?: boolean
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
return await this.checkIfCalendarConnected(userId);
|
return await this.checkIfCalendarConnected(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||||
|
|
||||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||||
@@ -60,7 +67,7 @@ export class OutlookService implements OAuthCalendarApp {
|
|||||||
client_id,
|
client_id,
|
||||||
prompt: "select_account",
|
prompt: "select_account",
|
||||||
redirect_uri: this.redirectUri,
|
redirect_uri: this.redirectUri,
|
||||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = stringify(params);
|
const query = stringify(params);
|
||||||
@@ -148,13 +155,19 @@ export class OutlookService implements OAuthCalendarApp {
|
|||||||
code: string,
|
code: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
origin: string,
|
origin: string,
|
||||||
redir?: string
|
redir?: string,
|
||||||
|
isDryRun?: boolean
|
||||||
) {
|
) {
|
||||||
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||||
if (!code || code === "undefined") {
|
if (!code || code === "undefined") {
|
||||||
return { url: redir || origin };
|
return { url: redir || origin };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if isDryRun is true we know its a dry run so we just redirect straight away
|
||||||
|
if (isDryRun) {
|
||||||
|
return { url: redir || origin };
|
||||||
|
}
|
||||||
|
|
||||||
const parsedCode = z.string().parse(code);
|
const parsedCode = z.string().parse(code);
|
||||||
|
|
||||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OrganizerIntegrationLocation } from "@calcom/lib";
|
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
|
||||||
|
|
||||||
type BaseEventType = {
|
type BaseEventType = {
|
||||||
length: number;
|
length: number;
|
||||||
|
|||||||
+70
-2
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
hideCalendarNotes: false,
|
hideCalendarNotes: false,
|
||||||
hideCalendarEventDetails: false,
|
hideCalendarEventDetails: false,
|
||||||
|
hideOrganizerEmail: false,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
body.lockTimeZoneToggleOnBookingPage
|
body.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
|
|||||||
requiresBookerEmailVerification: true,
|
requiresBookerEmailVerification: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
hideCalendarEventDetails: true,
|
hideCalendarEventDetails: true,
|
||||||
|
hideOrganizerEmail: true,
|
||||||
lockTimeZoneToggleOnBookingPage: true,
|
lockTimeZoneToggleOnBookingPage: true,
|
||||||
color: {
|
color: {
|
||||||
darkThemeHex: "#292929",
|
darkThemeHex: "#292929",
|
||||||
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||||
|
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
body.lockTimeZoneToggleOnBookingPage
|
body.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
|
|||||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||||
|
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
|
||||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||||
eventType.color = updatedEventType.color;
|
eventType.color = updatedEventType.color;
|
||||||
eventType.bookingFields = updatedEventType.bookingFields;
|
eventType.bookingFields = updatedEventType.bookingFields;
|
||||||
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||||
|
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||||
eventType.lockTimeZoneToggleOnBookingPage
|
eventType.lockTimeZoneToggleOnBookingPage
|
||||||
);
|
);
|
||||||
@@ -1420,7 +1428,6 @@ describe("Event types Endpoints", () => {
|
|||||||
{
|
{
|
||||||
name: "location",
|
name: "location",
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
label: "",
|
|
||||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
editable: "system",
|
editable: "system",
|
||||||
required: false,
|
required: false,
|
||||||
@@ -1550,7 +1557,6 @@ describe("Event types Endpoints", () => {
|
|||||||
{
|
{
|
||||||
name: "location",
|
name: "location",
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
label: "",
|
|
||||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||||
editable: "system",
|
editable: "system",
|
||||||
required: false,
|
required: false,
|
||||||
@@ -1775,6 +1781,68 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("creating event type with input of another event type output", () => {
|
||||||
|
let firstCreatedEventType: EventTypeOutput_2024_06_14;
|
||||||
|
let secondCreatedEventType: EventTypeOutput_2024_06_14;
|
||||||
|
|
||||||
|
it("should create first event type", async () => {
|
||||||
|
const body: CreateEventTypeInput_2024_06_14 = {
|
||||||
|
title: "first created coding class",
|
||||||
|
slug: "first-created-coding-class",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
type: "address",
|
||||||
|
address: "via volturno 10, Roma",
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
expect(createdEventType).toHaveProperty("id");
|
||||||
|
expect(createdEventType.title).toEqual(body.title);
|
||||||
|
expect(createdEventType.locations).toEqual(body.locations);
|
||||||
|
firstCreatedEventType = responseBody.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create second event type using first as input", async () => {
|
||||||
|
const body = {
|
||||||
|
...firstCreatedEventType,
|
||||||
|
title: "second created coding class",
|
||||||
|
slug: "second-created-coding-class",
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
expect(createdEventType).toHaveProperty("id");
|
||||||
|
expect(createdEventType.title).toEqual(body.title);
|
||||||
|
secondCreatedEventType = responseBody.data;
|
||||||
|
|
||||||
|
const { id, title, slug, ...restFirst } = firstCreatedEventType;
|
||||||
|
const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType;
|
||||||
|
expect(restFirst).toEqual(restSecond);
|
||||||
|
expect(id2).not.toEqual(id);
|
||||||
|
expect(title2).not.toEqual(title);
|
||||||
|
expect(slug2).not.toEqual(slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
|||||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
@@ -19,7 +18,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||||
providers: [
|
providers: [
|
||||||
EventTypesRepository_2024_06_14,
|
EventTypesRepository_2024_06_14,
|
||||||
EventTypesService_2024_06_14,
|
EventTypesService_2024_06_14,
|
||||||
|
|||||||
+33
-13
@@ -5,6 +5,9 @@ import { ConferencingService } from "@/modules/conferencing/services/conferencin
|
|||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CONFERENCING_APPS } from "@calcom/platform-constants";
|
||||||
|
import { getUsersCredentials } from "@calcom/platform-libraries";
|
||||||
|
import { getApps } from "@calcom/platform-libraries/app-store";
|
||||||
import {
|
import {
|
||||||
transformBookingFieldsApiToInternal,
|
transformBookingFieldsApiToInternal,
|
||||||
transformLocationsApiToInternal,
|
transformLocationsApiToInternal,
|
||||||
@@ -50,8 +53,7 @@ interface ValidationContext {
|
|||||||
export class InputEventTypesService_2024_06_14 {
|
export class InputEventTypesService_2024_06_14 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
private readonly calendarsService: CalendarsService,
|
private readonly calendarsService: CalendarsService
|
||||||
private readonly conferencingService: ConferencingService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async transformAndValidateCreateEventTypeInput(
|
async transformAndValidateCreateEventTypeInput(
|
||||||
@@ -104,13 +106,6 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
||||||
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
|
|
||||||
{
|
|
||||||
type: "integration",
|
|
||||||
integration: "cal-video",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
lengthInMinutes,
|
lengthInMinutes,
|
||||||
lengthInMinutesOptions,
|
lengthInMinutesOptions,
|
||||||
@@ -130,10 +125,11 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
} = inputEventType;
|
} = inputEventType;
|
||||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||||
|
|
||||||
|
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...rest,
|
...rest,
|
||||||
length: lengthInMinutes,
|
length: lengthInMinutes,
|
||||||
locations: this.transformInputLocations(locations || defaultLocations),
|
locations: locationsTransformed,
|
||||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||||
durationLimits: bookingLimitsDuration
|
durationLimits: bookingLimitsDuration
|
||||||
@@ -228,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
||||||
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
||||||
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
|
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
|
||||||
|
const systemCustomLocationField = systemCustomFields?.find((field) => field.name === "location");
|
||||||
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
|
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
|
||||||
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
||||||
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
||||||
@@ -237,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
||||||
systemCustomNameField || systemBeforeFieldName,
|
systemCustomNameField || systemBeforeFieldName,
|
||||||
systemCustomEmailField || systemBeforeFieldEmail,
|
systemCustomEmailField || systemBeforeFieldEmail,
|
||||||
systemBeforeFieldLocation,
|
systemCustomLocationField || systemBeforeFieldLocation,
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultFieldsAfter = [
|
const defaultFieldsAfter = [
|
||||||
@@ -278,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
field.name !== "title" &&
|
field.name !== "title" &&
|
||||||
field.name !== "notes" &&
|
field.name !== "notes" &&
|
||||||
field.name !== "guests" &&
|
field.name !== "guests" &&
|
||||||
field.name !== "rescheduleReason"
|
field.name !== "rescheduleReason" &&
|
||||||
|
field.name !== "location"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,10 +461,32 @@ export class InputEventTypesService_2024_06_14 {
|
|||||||
if (location.type === "integration") {
|
if (location.type === "integration") {
|
||||||
// cal-video is global, so we can skip this check
|
// cal-video is global, so we can skip this check
|
||||||
if (location.integration !== "cal-video") {
|
if (location.integration !== "cal-video") {
|
||||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
await this.checkAppIsValidAndConnected(user, location.integration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) ?? []
|
}) ?? []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
|
||||||
|
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||||
|
if (!conferencingApps.includes(appSlug)) {
|
||||||
|
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appSlug === "office365-video") {
|
||||||
|
appSlug = "msteams";
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await getUsersCredentials(user);
|
||||||
|
|
||||||
|
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
|
||||||
|
|
||||||
|
const appLocation = foundApp?.appData?.location;
|
||||||
|
|
||||||
|
if (!foundApp || !appLocation) {
|
||||||
|
throw new BadRequestException(`${appSlug} not connected.`);
|
||||||
|
}
|
||||||
|
return foundApp.credential;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -87,6 +87,7 @@ type Input = Pick<
|
|||||||
| "destinationCalendar"
|
| "destinationCalendar"
|
||||||
| "useEventTypeDestinationCalendarEmail"
|
| "useEventTypeDestinationCalendarEmail"
|
||||||
| "hideCalendarEventDetails"
|
| "hideCalendarEventDetails"
|
||||||
|
| "hideOrganizerEmail"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
seatsShowAttendees,
|
seatsShowAttendees,
|
||||||
useEventTypeDestinationCalendarEmail,
|
useEventTypeDestinationCalendarEmail,
|
||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
|
hideOrganizerEmail,
|
||||||
} = databaseEventType;
|
} = databaseEventType;
|
||||||
|
|
||||||
const locations = this.transformLocations(databaseEventType.locations);
|
const locations = this.transformLocations(databaseEventType.locations);
|
||||||
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
destinationCalendar,
|
destinationCalendar,
|
||||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
|
hideOrganizerEmail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -48,8 +46,6 @@ export class GcalController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly credentialRepository: CredentialsRepository,
|
private readonly credentialRepository: CredentialsRepository,
|
||||||
private readonly tokensRepository: TokensRepository,
|
|
||||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly gcalService: GCalService,
|
private readonly gcalService: GCalService,
|
||||||
private readonly calendarsService: CalendarsService
|
private readonly calendarsService: CalendarsService
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { MeController } from "@/ee/me/me.controller";
|
import { MeController } from "@/ee/me/me.controller";
|
||||||
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 { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
|
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
|
||||||
controllers: [MeController],
|
controllers: [MeController],
|
||||||
})
|
})
|
||||||
export class MeModule {}
|
export class MeModule {}
|
||||||
@@ -8,6 +8,7 @@ import { MeModule } from "@/ee/me/me.module";
|
|||||||
import { ProviderModule } from "@/ee/provider/provider.module";
|
import { ProviderModule } from "@/ee/provider/provider.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 { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||||
|
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||||
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
|
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
|
||||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
|
|||||||
SlotsModule_2024_04_15,
|
SlotsModule_2024_04_15,
|
||||||
SlotsModule_2024_09_04,
|
SlotsModule_2024_09_04,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
|
RoutingFormsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PlatformEndpointsModule implements NestModule {
|
export class PlatformEndpointsModule implements NestModule {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environme
|
|||||||
const value = process.env[key] as Environment[K] | undefined;
|
const value = process.env[key] as Environment[K] | undefined;
|
||||||
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
// handle fallback falsy cases that should still be used as value
|
||||||
|
if (fallback === false || fallback === "" || fallback === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ if (process.env.SENTRY_DSN) {
|
|||||||
// Ensure to call this before requiring any other modules!
|
// Ensure to call this before requiring any other modules!
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: getEnv("SENTRY_DSN"),
|
dsn: getEnv("SENTRY_DSN"),
|
||||||
integrations: [nodeProfilingIntegration()],
|
integrations: [nodeProfilingIntegration(), Sentry.prismaIntegration()],
|
||||||
// Performance Monitoring
|
// Performance Monitoring
|
||||||
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
||||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { RateLimitType } from "@/lib/throttler-guard";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export const Throttle = Reflector.createDecorator<RateLimitType>();
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { getPagination, clamp } from "./pagination";
|
||||||
|
|
||||||
|
describe("getPagination", () => {
|
||||||
|
it("handles the first page correctly", () => {
|
||||||
|
const pagination = getPagination({ skip: 0, take: 10, totalCount: 35 });
|
||||||
|
expect(pagination).toEqual({
|
||||||
|
returnedItems: 10,
|
||||||
|
totalItems: 35,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
remainingItems: 25,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 4,
|
||||||
|
hasNextPage: true,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a middle page correctly", () => {
|
||||||
|
const pagination = getPagination({ skip: 10, take: 10, totalCount: 35 });
|
||||||
|
expect(pagination).toEqual({
|
||||||
|
returnedItems: 10,
|
||||||
|
totalItems: 35,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
remainingItems: 15,
|
||||||
|
currentPage: 2,
|
||||||
|
totalPages: 4,
|
||||||
|
hasNextPage: true,
|
||||||
|
hasPreviousPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the last page when it is not completely full", () => {
|
||||||
|
const pagination = getPagination({ skip: 30, take: 10, totalCount: 35 });
|
||||||
|
expect(pagination).toEqual({
|
||||||
|
returnedItems: 5,
|
||||||
|
totalItems: 35,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
remainingItems: 0,
|
||||||
|
currentPage: 4,
|
||||||
|
totalPages: 4,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps skip values that exceed the total item count", () => {
|
||||||
|
const pagination = getPagination({ skip: 40, take: 10, totalCount: 35 });
|
||||||
|
expect(pagination).toEqual({
|
||||||
|
returnedItems: 0,
|
||||||
|
totalItems: 35,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
remainingItems: 0,
|
||||||
|
currentPage: 4,
|
||||||
|
totalPages: 4,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when itemsPerPage (take) is zero", () => {
|
||||||
|
const pagination = getPagination({ skip: 0, take: 0, totalCount: 35 });
|
||||||
|
expect(pagination).toEqual({
|
||||||
|
returnedItems: 0,
|
||||||
|
totalItems: 35,
|
||||||
|
itemsPerPage: 0,
|
||||||
|
remainingItems: 35,
|
||||||
|
currentPage: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasNextPage: true,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when the collection is empty", () => {
|
||||||
|
const pagination = getPagination({ skip: 0, take: 10, totalCount: 0 });
|
||||||
|
expect(pagination).toEqual({
|
||||||
|
returnedItems: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
remainingItems: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clamp", () => {
|
||||||
|
it("returns the value unchanged when it is inside the range", () => {
|
||||||
|
expect(clamp({ value: 5, min: 1, max: 10 })).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps values below the minimum", () => {
|
||||||
|
expect(clamp({ value: -3, min: 0, max: 10 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps values above the maximum", () => {
|
||||||
|
expect(clamp({ value: 42, min: 0, max: 10 })).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { PaginationMetaDto } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
type Pagination = {
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPagination(pagination: Pagination): PaginationMetaDto {
|
||||||
|
const { skip, take, totalCount } = pagination;
|
||||||
|
|
||||||
|
const safeSkip = clamp({ value: skip, min: 0, max: totalCount });
|
||||||
|
const itemsPerPage = take;
|
||||||
|
const remainingItems = getRemainingItems(safeSkip, itemsPerPage, totalCount);
|
||||||
|
const totalPages = getTotalPages(itemsPerPage, totalCount);
|
||||||
|
const currentPage = getCurrentPage(safeSkip, itemsPerPage, totalPages);
|
||||||
|
const hasNextPage = getHasNextPage(safeSkip, itemsPerPage, totalCount);
|
||||||
|
const hasPreviousPage = getHasPreviousPage(safeSkip);
|
||||||
|
const returnedItems = getReturnedItems(safeSkip, itemsPerPage, totalCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
returnedItems,
|
||||||
|
totalItems: totalCount,
|
||||||
|
itemsPerPage,
|
||||||
|
remainingItems,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemainingItems(skip: number, itemsPerPage: number, totalCount: number) {
|
||||||
|
return clamp({
|
||||||
|
value: totalCount - (skip + itemsPerPage),
|
||||||
|
min: 0,
|
||||||
|
max: totalCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalPages(itemsPerPage: number, totalCount: number) {
|
||||||
|
return itemsPerPage !== 0 ? Math.ceil(totalCount / itemsPerPage) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPage(skip: number, itemsPerPage: number, totalPages: number) {
|
||||||
|
const rawCurrentPage = Math.floor(skip / itemsPerPage) + 1;
|
||||||
|
if (totalPages === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return clamp({ value: rawCurrentPage, min: 1, max: totalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHasNextPage(skip: number, itemsPerPage: number, totalCount: number) {
|
||||||
|
return skip + itemsPerPage < totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHasPreviousPage(skip: number) {
|
||||||
|
return skip > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReturnedItems(skip: number, itemsPerPage: number, totalCount: number) {
|
||||||
|
const remaining = totalCount - skip;
|
||||||
|
return clamp({
|
||||||
|
value: remaining,
|
||||||
|
min: 0,
|
||||||
|
max: itemsPerPage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClampArgs = {
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function clamp({ value, min, max }: ClampArgs): number {
|
||||||
|
return Math.max(min, Math.min(value, max));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getEnv } from "@/env";
|
import { getEnv } from "@/env";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
|
|||||||
ttl: z.number(),
|
ttl: z.number(),
|
||||||
blockDuration: z.number(),
|
blockDuration: z.number(),
|
||||||
});
|
});
|
||||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
export type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||||
|
|
||||||
const sixtySecondsMs = 60 * 1000;
|
const sixtySecondsMs = 60 * 1000;
|
||||||
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||||
const { context } = requestProps;
|
const { context } = requestProps;
|
||||||
|
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (throttleOptions) {
|
||||||
|
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||||
|
}
|
||||||
|
|
||||||
if (tracker.startsWith("api_key_")) {
|
if (tracker.startsWith("api_key_")) {
|
||||||
return this.handleApiKeyRequest(tracker, response);
|
return this.handleApiKeyRequest(tracker, response);
|
||||||
} else {
|
} else {
|
||||||
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
|
||||||
|
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
|
||||||
|
if (isBlocked) {
|
||||||
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||||
|
|
||||||
@@ -82,7 +96,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allLimitsBlocked) {
|
if (allLimitsBlocked) {
|
||||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -96,7 +110,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -150,7 +164,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!apiKeyRecord) {
|
if (!apiKeyRecord) {
|
||||||
throw new UnauthorizedException("Invalid API Key");
|
throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
|
||||||
}
|
}
|
||||||
|
|
||||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export class EventTypesAtomService {
|
|||||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||||
|
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||||
|
eventType.teamMembers = onlyManagedTeamMembers;
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,20 +226,26 @@ export class EventTypesAtomService {
|
|||||||
} else {
|
} else {
|
||||||
credentials = credentials.concat(teamAppCredentials);
|
credentials = credentials.concat(teamAppCredentials);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (slug !== "stripe") {
|
||||||
|
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
||||||
|
const { credentials: allCredentials } =
|
||||||
|
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
credentials,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
credentials = allCredentials;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
|
||||||
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
|
const enabledApps = await getEnabledAppsFromCredentials(
|
||||||
|
credentials as unknown as CredentialDataWithTeamName[],
|
||||||
{
|
{
|
||||||
user: {
|
where: { slug },
|
||||||
...user,
|
|
||||||
credentials,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
credentials = allCredentials;
|
|
||||||
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
|
|
||||||
where: { slug },
|
|
||||||
});
|
|
||||||
const apps = await Promise.all(
|
const apps = await Promise.all(
|
||||||
enabledApps
|
enabledApps
|
||||||
.filter(({ ...app }) => app.slug === slug)
|
.filter(({ ...app }) => app.slug === slug)
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
|
||||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
|
||||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
|
||||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
forwardRef(() => OrganizationsModule),
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
NextAuthGuard,
|
|
||||||
NextAuthStrategy,
|
|
||||||
ApiAuthGuard,
|
|
||||||
ApiAuthStrategy,
|
|
||||||
OAuthFlowService,
|
|
||||||
IsTeamInOrg,
|
|
||||||
RolesGuard,
|
|
||||||
IsOrgGuard,
|
|
||||||
IsAdminAPIEnabledGuard,
|
|
||||||
PlatformPlanGuard,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
NextAuthGuard,
|
|
||||||
ApiAuthGuard,
|
|
||||||
IsTeamInOrg,
|
|
||||||
RolesGuard,
|
|
||||||
IsOrgGuard,
|
|
||||||
IsAdminAPIEnabledGuard,
|
|
||||||
PlatformPlanGuard,
|
|
||||||
],
|
],
|
||||||
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
|
||||||
|
|
||||||
|
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import { ExecutionContext, Inject } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
|
||||||
|
import { ApiAuthGuardOnlyAllow } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||||
|
|
||||||
export class ApiAuthGuard extends AuthGuard("api-auth") {
|
export class ApiAuthGuard extends AuthGuard("api-auth") {
|
||||||
constructor() {
|
constructor(@Inject(Reflector) private readonly reflector: Reflector) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRequest(context: ExecutionContext) {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const allowedMethods = this.reflector.get(ApiAuthGuardOnlyAllow, context.getHandler());
|
||||||
|
request.allowedAuthMethods = allowedMethods;
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -104,13 +104,6 @@ describe("PlatformPlanGuard", () => {
|
|||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
|
|
||||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
|
||||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||||
return createMock<ExecutionContext>({
|
return createMock<ExecutionContext>({
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
|
|||||||
@@ -16,65 +16,63 @@ export class PlatformPlanGuard implements CanActivate {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const teamId = request.params.teamId as string;
|
|
||||||
const orgId = request.params.orgId as string;
|
|
||||||
const user = request.user as ApiAuthGuardUser;
|
|
||||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
if (!minimumPlan) {
|
||||||
return canAccess;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const orgId = request.params.orgId;
|
||||||
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException("PlatformPlanGuard - No user associated with the request.");
|
||||||
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
throw new ForbiddenException("PlatformPlanGuard - No organization associated with the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.checkPlatformPlanAccess(orgId, minimumPlan);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPlatformPlanAccess({
|
async checkPlatformPlanAccess(orgId: string, minimumPlan: PlatformPlanType) {
|
||||||
teamId,
|
const REDIS_CACHE_KEY = `apiv2:org:${orgId}:guard:platformbilling:${minimumPlan}`;
|
||||||
orgId,
|
const cachedValue = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||||
user,
|
if (cachedValue !== null) {
|
||||||
minimumPlan,
|
return cachedValue === "true";
|
||||||
}: {
|
|
||||||
teamId?: string;
|
|
||||||
orgId?: string;
|
|
||||||
user: ApiAuthGuardUser;
|
|
||||||
minimumPlan: PlatformPlanType;
|
|
||||||
}): Promise<{ canAccess: boolean }> {
|
|
||||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
|
||||||
teamId ?? "none"
|
|
||||||
}:guard:platformbilling:${minimumPlan}`;
|
|
||||||
|
|
||||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
|
||||||
|
|
||||||
if (cachedAccess) {
|
|
||||||
return { canAccess: cachedAccess };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let canAccess = false;
|
const organization = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||||
|
const isPlatform = organization?.isPlatform;
|
||||||
|
const hasSubscription = organization?.platformBilling?.subscriptionId;
|
||||||
|
|
||||||
if (user && orgId) {
|
if (!organization) {
|
||||||
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
throw new ForbiddenException(`PlatformPlanGuard - No organization found with id=${orgId}.`);
|
||||||
const isPlatform = team?.isPlatform;
|
}
|
||||||
const hasSubscription = team?.platformBilling?.subscriptionId;
|
if (!isPlatform) {
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||||
if (!team) {
|
return true;
|
||||||
canAccess = false;
|
}
|
||||||
} else if (!isPlatform) {
|
if (!hasSubscription) {
|
||||||
canAccess = true;
|
throw new ForbiddenException(
|
||||||
} else if (!hasSubscription) {
|
`PlatformPlanGuard - No platform subscription found for organization with id=${orgId}.`
|
||||||
canAccess = false;
|
);
|
||||||
} else {
|
}
|
||||||
canAccess = hasMinimumPlan({
|
if (
|
||||||
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
!hasMinimumPlan({
|
||||||
minimumPlan: minimumPlan,
|
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
|
||||||
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
minimumPlan: minimumPlan,
|
||||||
});
|
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||||
}
|
})
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PlatformPlanGuard - organization with id=${orgId} does not have required plan for this operation. Minimum plan is ${minimumPlan} while the organization has ${
|
||||||
|
organization.platformBilling?.plan || "undefined"
|
||||||
|
}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||||
if (canAccess) {
|
return true;
|
||||||
return { canAccess };
|
|
||||||
}
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +88,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
|||||||
|
|
||||||
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export class IsMembershipInOrg implements CanActivate {
|
|||||||
const orgId: string = request.params.orgId;
|
const orgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsMembershipInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!membershipId) {
|
if (!membershipId) {
|
||||||
throw new ForbiddenException("No membership id found in request params.");
|
throw new ForbiddenException("IsMembershipInOrg - No membership id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||||
@@ -33,7 +33,7 @@ export class IsMembershipInOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new NotFoundException(`Membership (${membershipId}) not found.`);
|
throw new NotFoundException(`IsMembershipInOrg - Membership (${membershipId}) not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.membership = membership;
|
request.membership = membership;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api
|
|||||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||||
handleRequest(error: Error, user: any) {
|
handleRequest(error: Error, user: any) {
|
||||||
// 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 === NO_AUTH_PROVIDED_MESSAGE;
|
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||||
if (user || noAuthProvided || !error) {
|
if (user || noAuthProvided || !error) {
|
||||||
return user || null;
|
return user || null;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,14 +22,19 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsAdminAPIEnabledGuard - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||||
if (organization) {
|
if (organization) {
|
||||||
request.organization = organization;
|
request.organization = organization;
|
||||||
}
|
}
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsAdminAPIEnabledGuard - Organization with id=${organizationId} does not have Admin API access. Please contact https://cal.com/sales to upgrade.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAdminAPIEnabled(
|
async checkAdminAPIEnabled(
|
||||||
@@ -57,18 +62,21 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
if (!adminAPIAccessIsEnabledInOrg) {
|
if (!adminAPIAccessIsEnabledInOrg) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
`IsAdminAPIEnabledGuard - Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
canAccess = true;
|
canAccess = true;
|
||||||
org &&
|
|
||||||
(await this.redisService.redis.set(
|
if (org && canAccess) {
|
||||||
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
"EX",
|
"EX",
|
||||||
300
|
300
|
||||||
));
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { canAccess, organization: org };
|
return { canAccess, organization: org };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 1528 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user