Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf9c881d16 | ||
|
|
6dd0f9ce29 | ||
|
|
d34db7aef6 | ||
|
|
b4b90f2d27 | ||
|
|
3f0380617a | ||
|
|
7143923ea4 | ||
|
|
dfccf4604f | ||
|
|
c12abd75a8 | ||
|
|
2e12f09f05 | ||
|
|
4258da1df5 | ||
|
|
41ef354c7b | ||
|
|
8891641953 | ||
|
|
0150041496 | ||
|
|
13f9e97015 | ||
|
|
3c5901edb8 | ||
|
|
d26bab5880 | ||
|
|
1c451a8d51 | ||
|
|
576261bf88 | ||
|
|
7d944af7f6 | ||
|
|
dfe7056c93 | ||
|
|
b27f01449a | ||
|
|
6dd77d0494 | ||
|
|
f73d38b632 | ||
|
|
a7d8f0c84e | ||
|
|
b0164990c0 | ||
|
|
bad72ba7bf | ||
|
|
1b317a98a4 | ||
|
|
6a22d75caa | ||
|
|
a030861423 | ||
|
|
bd9cd00c04 | ||
|
|
6fd34af6f6 | ||
|
|
1a85f336c5 | ||
|
|
cc5096a54a | ||
|
|
eea7059226 | ||
|
|
63f3247b1b | ||
|
|
7a769c38fb | ||
|
|
34c2565c70 | ||
|
|
a130804f18 | ||
|
|
446e851065 | ||
|
|
f06b4bfb9c | ||
|
|
1896efa3a0 | ||
|
|
62c8603863 | ||
|
|
7d8559b0b0 | ||
|
|
7a48bbe553 | ||
|
|
817e95c246 | ||
|
|
4ac6225a35 | ||
|
|
38d9b93dee | ||
|
|
376b38d4ed | ||
|
|
0e181f6d9f | ||
|
|
4dbb692156 | ||
|
|
0068718d24 | ||
|
|
ac817b3542 | ||
|
|
97f2cffd1f | ||
|
|
b6dea9c161 | ||
|
|
57eb4746ad | ||
|
|
eff9c87964 | ||
|
|
e06c07bfec | ||
|
|
332c00dac2 | ||
|
|
e3523db6bb | ||
|
|
9c0f046b2a | ||
|
|
6e1a717ecf | ||
|
|
b2e098c781 | ||
|
|
3010900358 | ||
|
|
9b043f44a0 | ||
|
|
814674fe40 | ||
|
|
acaa79b0b8 | ||
|
|
781a0f4a7c | ||
|
|
b2028870d0 | ||
|
|
8f70be8861 | ||
|
|
53c8f8c04a | ||
|
|
c4e5d168a5 | ||
|
|
500bfb4d82 | ||
|
|
024b92b9ce | ||
|
|
f23b63cc98 | ||
|
|
edc90e3f27 | ||
|
|
05631d093f | ||
|
|
6000ff4b48 | ||
|
|
cd7de98e3f | ||
|
|
3f51858d27 | ||
|
|
2c498ace5c | ||
|
|
595d781d51 | ||
|
|
3c508568da | ||
|
|
3204d0af34 | ||
|
|
25684f9040 | ||
|
|
bc9aeef710 | ||
|
|
393411a47a | ||
|
|
356117feaf | ||
|
|
1bf68d50e0 | ||
|
|
4a35e85756 | ||
|
|
a4f56bf680 | ||
|
|
a308075bc3 | ||
|
|
efa6d464a3 | ||
|
|
811262d7ee | ||
|
|
99a5dcf96a | ||
|
|
79f092673a | ||
|
|
b285f27d00 | ||
|
|
1fa87ae179 | ||
|
|
f6f4b67bf9 | ||
|
|
8c8550e6f8 | ||
|
|
c6246c9408 | ||
|
|
ccc814eb54 | ||
|
|
2ee0ec11d6 | ||
|
|
3223beb9ac | ||
|
|
c1bcfcfa3d | ||
|
|
83aea7d28c | ||
|
|
a810a538e9 | ||
|
|
1cd0d7b5ca | ||
|
|
4540c79580 | ||
|
|
00d01882d4 | ||
|
|
6ce4122528 | ||
|
|
7ddbcfcdcf | ||
|
|
b1b01ed383 | ||
|
|
6f5d9dc41f | ||
|
|
ac190b89dd | ||
|
|
990441295c | ||
|
|
1e4c20073c | ||
|
|
80353d67a0 | ||
|
|
d55b4b051a | ||
|
|
25d90e3e12 | ||
|
|
1727eb5625 | ||
|
|
58f4224c66 | ||
|
|
5d491e0a12 | ||
|
|
1eef5a56c3 | ||
|
|
d6740503be | ||
|
|
9f647ead46 | ||
|
|
f480afcace | ||
|
|
8d73d15b8a | ||
|
|
e8370ef950 | ||
|
|
e859c8dc58 | ||
|
|
b4b38b9bc3 | ||
|
|
d32719ad8b | ||
|
|
99d0816d9f | ||
|
|
95f4b89fe8 | ||
|
|
3e63c2b717 | ||
|
|
42cd56578d | ||
|
|
31d4cb4eb7 | ||
|
|
fde3e8bea9 | ||
|
|
874ad29676 | ||
|
|
5503d9d432 | ||
|
|
43b3d68447 | ||
|
|
e3c747a867 | ||
|
|
e020a2c9a4 | ||
|
|
e6e6f09547 | ||
|
|
68ec946c59 | ||
|
|
5a6e5ce427 | ||
|
|
b2382211db | ||
|
|
12148506bd | ||
|
|
b5666fd4c3 | ||
|
|
bc7da563d8 | ||
|
|
d3acf4160d | ||
|
|
65e0c0e4c5 | ||
|
|
2dda6a509b | ||
|
|
7238414864 | ||
|
|
bab41ca8a6 | ||
|
|
376f023e0f | ||
|
|
0cbf525a78 | ||
|
|
f56fefec4d | ||
|
|
66eccd6dd8 | ||
|
|
c4b9a9e2f4 | ||
|
|
38921e58cd | ||
|
|
2c9c6c77f7 | ||
|
|
46605e049d | ||
|
|
01e1e82ced | ||
|
|
8e17716094 | ||
|
|
7c028b6479 | ||
|
|
cba4bae5cd | ||
|
|
55046f3017 | ||
|
|
4836621c31 | ||
|
|
6743aa4609 | ||
|
|
bbad0fbc18 | ||
|
|
8f6812ecb2 | ||
|
|
303b09f341 | ||
|
|
1f3ee41eb4 | ||
|
|
01c711a81e | ||
|
|
16bb5c3384 | ||
|
|
c55d97368e | ||
|
|
ef193aca31 | ||
|
|
c36a6797a9 | ||
|
|
f393fd4ad4 | ||
|
|
8987b9bdbc | ||
|
|
68bf426af0 | ||
|
|
1125900013 | ||
|
|
d272e823c3 | ||
|
|
3ea11d4f38 | ||
|
|
b4dc64594c | ||
|
|
6a763b3720 | ||
|
|
252ac1c704 | ||
|
|
c51e42a061 | ||
|
|
e3f9e477fe | ||
|
|
b265970b01 | ||
|
|
7b7b2ffc9a | ||
|
|
d019062709 | ||
|
|
ad9d728314 | ||
|
|
b564fb9a2f | ||
|
|
9c05f6839c | ||
|
|
4dea38f06d | ||
|
|
c4fc222204 | ||
|
|
00c3a2dd80 | ||
|
|
46fa0088f2 | ||
|
|
a19fd9b889 | ||
|
|
d379c10510 | ||
|
|
f3a58145ee | ||
|
|
e6125dc361 | ||
|
|
b78f718e9e | ||
|
|
53e87f7769 | ||
|
|
86eb40e0fd | ||
|
|
51caa6834a | ||
|
|
7a7a5b5285 | ||
|
|
f14ffb9e44 | ||
|
|
001b524005 | ||
|
|
a8cf4fb309 | ||
|
|
155775556d | ||
|
|
d66095bf9c | ||
|
|
0ddab30340 | ||
|
|
2a9e061575 | ||
|
|
351dce5cfd | ||
|
|
3255d9012c | ||
|
|
226dcb3fcf | ||
|
|
f80e9b2558 | ||
|
|
3f273235a6 | ||
|
|
e8554ed5c5 | ||
|
|
a03744008c | ||
|
|
fe8b13885c | ||
|
|
8995dcc82a | ||
|
|
e8e0d03265 | ||
|
|
56031f0358 | ||
|
|
1ef0f830d3 | ||
|
|
59e2f6e4a7 | ||
|
+15 |
67bb2ed798 | ||
|
|
ea4618a244 | ||
|
|
201a9d9356 | ||
|
|
40b0d57024 | ||
|
|
761f66fc5a | ||
|
|
2403d77166 | ||
|
|
b44f4d66f9 | ||
|
|
d178962c37 | ||
|
|
cf4d2b4ef5 | ||
|
|
3e4139ac23 | ||
|
|
9fb736f67a | ||
|
|
afc738141f | ||
|
|
efa195059e | ||
|
|
0f2b6bbe1a | ||
|
|
28b94a865f | ||
|
|
f45803fcfa | ||
|
|
053da1ec32 | ||
|
|
f0dcfc83c8 | ||
|
|
5eb3af54b3 | ||
|
|
3552bbc6cf | ||
|
|
028bd77c91 | ||
|
|
65a1aa6100 | ||
|
|
a09401c203 | ||
|
|
7c23773636 | ||
|
|
56ba8c0a07 | ||
|
|
5924bc0990 | ||
|
+27 |
10ffd9bacd | ||
|
|
20c45b5d88 | ||
|
|
3cab37ed8d | ||
|
|
98799b50f0 | ||
|
|
8071016848 | ||
|
|
aa5e2ac9a5 | ||
|
|
06857a4a69 | ||
|
|
b0d1720d92 | ||
|
|
e148ee7823 | ||
|
|
bb9a039931 | ||
|
|
053f93ea0a | ||
|
|
2a982c6b6d | ||
|
|
85490fef4d | ||
|
|
57afd7a2b1 | ||
|
|
0f9c3571fe | ||
|
|
1b3f676e57 | ||
|
|
3e17b1c5ef | ||
|
|
a466e71d4c | ||
|
|
5a7030d277 | ||
|
|
e72c25f02e | ||
|
|
06bc5facf3 | ||
|
|
9c8dc1b92c | ||
|
|
cfef92a7bc | ||
|
|
26149e2b04 | ||
|
|
3ca43ce3f3 | ||
|
|
7603ea6184 | ||
|
|
9c45da565e | ||
|
|
f84f1f5dda | ||
|
|
07f803e758 | ||
|
|
268ab882cd | ||
|
|
8052e6f6d4 | ||
|
|
99e9425257 | ||
|
|
471b2687c4 | ||
|
|
9f7152c4eb | ||
|
|
e819a31dd6 | ||
|
|
8356cb7d34 | ||
|
|
ceb18167cf | ||
|
|
4d5697f5a5 | ||
|
|
afe180a0ec | ||
|
|
851a388853 | ||
|
|
07e20144df | ||
|
|
1535e48ce5 | ||
|
|
0dce391f46 | ||
|
|
cfe7ea45ec | ||
|
|
c244f0fc8e | ||
|
|
5301af2e9f | ||
|
|
a4e7ca91b5 | ||
|
|
64e6e1a19a | ||
|
|
a6daf17909 | ||
|
|
c7dfa7bc89 | ||
|
|
633d85e81b | ||
|
|
49a61207d5 | ||
|
|
f3eb490e3b | ||
|
|
2de5f57437 | ||
|
|
8bdd835a68 | ||
|
|
9c7d0ea604 | ||
|
|
a3aa53fe6e | ||
|
|
57384eb921 | ||
|
|
a153f9627d | ||
|
|
a54539d211 | ||
|
|
8d736cfb67 | ||
|
|
3c3564f884 | ||
|
|
cc75899b01 | ||
|
|
1a818ab0c3 | ||
|
|
7dafaf1381 | ||
|
|
74f354abd2 | ||
|
|
f281131bf6 | ||
|
|
22b4bcdeeb | ||
|
|
5f59ef2acf | ||
|
|
d6d4e246f9 | ||
|
|
a49c34e733 | ||
|
|
37ce8860b5 | ||
|
|
4dfbc46247 | ||
|
|
4c38ea3329 | ||
|
|
24e60498f4 | ||
|
|
fa9a8cca89 | ||
|
|
38bb01a9f0 | ||
|
|
3cf6fef159 | ||
|
|
26dac453e9 | ||
|
|
52db023f76 | ||
|
|
df9e02d138 | ||
|
|
d7be40f67b | ||
|
|
68d2a97e92 | ||
|
|
cde9b24a9b | ||
|
|
cf89598279 | ||
|
|
fcd892bfa0 | ||
|
|
bee5011ec1 |
@@ -1,6 +1,7 @@
|
||||
# ********** INDEX **********
|
||||
#
|
||||
# - APP STORE
|
||||
# - BASECAMP
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
@@ -20,6 +21,14 @@
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
|
||||
# - BASECAMP
|
||||
# Used to enable Basecamp integration with Cal.com
|
||||
# @see https://github.com/calcom/cal.com#obtaining-basecamp-client-id-and-secret
|
||||
BASECAMP3_CLIENT_ID=
|
||||
BASECAMP3_CLIENT_SECRET=
|
||||
BASECAMP3_USER_AGENT=
|
||||
|
||||
# - DAILY.CO VIDEO
|
||||
# Enables Cal Video. to get your key
|
||||
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
|
||||
+12
-2
@@ -126,8 +126,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
# 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=
|
||||
|
||||
# Used for internal billing system
|
||||
@@ -172,6 +171,12 @@ EMAIL_SERVER_PORT=1025
|
||||
## You will need to provision an App Password.
|
||||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||
|
||||
# Used for E2E for email testing
|
||||
# Set it to "1" if you need to email checks in E2E tests locally
|
||||
# Make sure to run mailhog container manually or with `yarn dx`
|
||||
E2E_TEST_MAILHOG_ENABLED=
|
||||
|
||||
# **********************************************************************************************************
|
||||
|
||||
# Set the following value to true if you wish to enable Team Impersonation
|
||||
@@ -215,3 +220,8 @@ PROJECT_ID_VERCEL=
|
||||
TEAM_ID_VERCEL=
|
||||
# Get it from: https://vercel.com/account/tokens
|
||||
AUTH_BEARER_TOKEN_VERCEL=
|
||||
|
||||
# - APPLE CALENDAR
|
||||
# Used for E2E tests on Apple Calendar
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Add PRs to project Reviewing PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add-PR-to-project:
|
||||
name: Add PRs to project Reviewing PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/11
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
@@ -5,16 +5,15 @@ on:
|
||||
types:
|
||||
- opened
|
||||
|
||||
|
||||
jobs:
|
||||
label_on_pr:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Apply labels from linked issue to PR
|
||||
uses: actions/github-script@v5
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# .github/workflows/chromatic.yml
|
||||
|
||||
# Workflow name
|
||||
name: 'Chromatic'
|
||||
name: "Chromatic"
|
||||
|
||||
# Event for the workflow
|
||||
on:
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||
- cron: "*/15 * * * *"
|
||||
jobs:
|
||||
cron-bookingReminder:
|
||||
env:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
-sSf
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 month at 1st (see https://crontab.guru)
|
||||
# Runs "At 00:00 on day-of-month 1." (see https://crontab.guru)
|
||||
- cron: "0 0 1 * *"
|
||||
jobs:
|
||||
cron-downgradeUsers:
|
||||
@@ -21,4 +21,4 @@ jobs:
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
-sSf
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||
- cron: "*/15 * * * *"
|
||||
jobs:
|
||||
cron-scheduleEmailReminders:
|
||||
env:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
-sSf
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||
- cron: "*/15 * * * *"
|
||||
jobs:
|
||||
cron-scheduleSMSReminders:
|
||||
env:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
-sSf
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
# Runs "At every 15th minute." (see https://crontab.guru)
|
||||
- cron: "*/15 * * * *"
|
||||
jobs:
|
||||
cron-scheduleWhatsappReminders:
|
||||
env:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
-sSf
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 day (see https://crontab.guru)
|
||||
# Runs "At 00:00." every day (see https://crontab.guru)
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 month at 1st (see https://crontab.guru)
|
||||
# Runs "At 00:00 on day-of-month 1." (see https://crontab.guru)
|
||||
- cron: "0 0 1 * *"
|
||||
jobs:
|
||||
cron-syncAppMeta:
|
||||
@@ -21,4 +21,4 @@ jobs:
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
-sSf
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - webhookTriggers
|
||||
|
||||
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 5 minutes” (see https://crontab.guru)
|
||||
- cron: "*/5 * * * *"
|
||||
jobs:
|
||||
cron-webhookTriggers:
|
||||
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/webhookTriggers \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
|
||||
@@ -38,6 +38,8 @@ jobs:
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
|
||||
@@ -40,11 +40,14 @@ jobs:
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
|
||||
@@ -7,6 +7,8 @@ env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
|
||||
@@ -7,6 +7,8 @@ env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
|
||||
@@ -8,7 +8,7 @@ on: # yamllint disable-line rule:truthy
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
RELEASE_TAG:
|
||||
description: 'v{Major}.{Minor}.{Patch}'
|
||||
description: "v{Major}.{Minor}.{Patch}"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: "Determine tag"
|
||||
run: "echo \"RELEASE_TAG=${GITHUB_REF#refs/tags/}\" >> $GITHUB_ENV"
|
||||
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
|
||||
|
||||
- name: "Run remote release workflow"
|
||||
uses: "actions/github-script@v6"
|
||||
|
||||
@@ -29,11 +29,11 @@ jobs:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
|
||||
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||
|
||||
Details:
|
||||
|
||||
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions! 🙏
|
||||
Thank you for following the naming conventions! 🙏
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Submodule Sync
|
||||
on:
|
||||
schedule:
|
||||
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
|
||||
- cron: "15 */4 * * *"
|
||||
workflow_dispatch: ~
|
||||
jobs:
|
||||
|
||||
+1
-1
@@ -40,4 +40,4 @@ vscode:
|
||||
- bradlc.vscode-tailwindcss
|
||||
- ban.spellright
|
||||
- stripe.vscode-stripe
|
||||
- Prisma.prisma
|
||||
- Prisma.prisma
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
@@ -37,7 +37,7 @@ Contributions are what make the open source community such an amazing place to l
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Features (Booking page, availabilty, timezone calculation)
|
||||
Core Features (Booking page, availability, timezone calculation)
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||
@@ -132,7 +132,6 @@ If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
|
||||
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
# Scheduling infrastructure for absolutely everyone
|
||||
|
||||
The open source Calendly alternative. You are in charge
|
||||
of your own data, workflow and appearance.
|
||||
of your own data, workflow, and appearance.
|
||||
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customizations.
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes, and even calls with our families. However, most tools are very limited in terms of control and customization.
|
||||
|
||||
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
||||
|
||||
@@ -98,7 +98,7 @@ Meet our sales team for any commercial inquiries.
|
||||
|
||||
## Stay Up-to-Date
|
||||
|
||||
Cal.com officially launched as v.1.0 on 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||
Cal.com officially launched as v.1.0 on the 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||
|
||||

|
||||
|
||||
@@ -128,7 +128,7 @@ Here is what you need to be able to run Cal.com.
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
```
|
||||
|
||||
> If you are on windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
||||
|
||||
1. Go to the project folder
|
||||
@@ -221,6 +221,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
```
|
||||
|
||||
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
||||
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
||||
|
||||
```sh
|
||||
docker pull mailhog/mailhog
|
||||
@@ -254,7 +255,7 @@ Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
# To open the last HTML report run:
|
||||
yarn playwright show-report test-results/reports/playwright-html-report
|
||||
```
|
||||
|
||||
@@ -422,7 +423,7 @@ yarn seed-app-store
|
||||
```
|
||||
|
||||
You will need to complete a few more steps to activate Google Calendar App.
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After the do the
|
||||
Make sure to complete section "Obtaining the Google API Credentials". After that do the
|
||||
following
|
||||
|
||||
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
||||
@@ -448,8 +449,8 @@ following
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
|
||||
@@ -461,6 +462,18 @@ following
|
||||
4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file.
|
||||
5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
### Obtaining Basecamp Client ID and Secret
|
||||
|
||||
1. Visit the [37 Signals Integrations Dashboard](launchpad.37signals.com/integrations) and sign in.
|
||||
2. Register a new application by clicking the Register one now link.
|
||||
3. Fill in your company details.
|
||||
4. Select Basecamp 4 as the product to integrate with.
|
||||
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
||||
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
||||
For example, `Cal.com (support@cal.com)`.
|
||||
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
BACKEND_URL=http://localhost:3002/api
|
||||
|
||||
APP_ID=cal-ai
|
||||
APP_URL=http://localhost:3000/apps/cal-ai
|
||||
|
||||
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||
PARSE_KEY=
|
||||
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optionally trace completions at https://smith.langchain.com
|
||||
# LANGCHAIN_TRACING_V2=true
|
||||
# LANGCHAIN_ENDPOINT=
|
||||
# LANGCHAIN_API_KEY=
|
||||
# LANGCHAIN_PROJECT=
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cal.com Email Assistant
|
||||
|
||||
Welcome to the first stage of Cal AI!
|
||||
|
||||
This app lets you chat with your calendar via email:
|
||||
|
||||
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||
- List and rearrange your bookings eg. "Cancel my next meeting"
|
||||
- Answer basic questions about your busiest times eg. "How does my Tuesday look?"
|
||||
|
||||
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||
|
||||
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||
|
||||
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making it hard to spoof them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||
|
||||
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. You'll need:
|
||||
|
||||
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||
- A default sender email (for example, `ai@cal.dev`)
|
||||
- The Cal AI's app ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||
|
||||
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
|
||||
To forward incoming emails to the Node.js server, one option is to use [SendGrid's Inbound Parse Webhook](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
1. [Sign up for an account](https://signup.sendgrid.com/)
|
||||
2. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL.
|
||||
3. For subdomain, use `<sub>.<domain>.com` for now, where `sub` can be any subdomain but `domain.com` will need to be verified via MX records in your environment variables, eg. on [Vercel](https://vercel.com/guides/how-to-add-vercel-environment-variables).
|
||||
4. Use the nGrok URL from above as the **Destination URL**.
|
||||
5. Activate "POST the raw, full MIME message".
|
||||
6. Send an email to `<anyone>@ai.example.com`. You should see a ping on the nGrok listener and Node.js server.
|
||||
7. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||
|
||||
Please feel free to improve any part of this architecture.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,24 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/",
|
||||
destination: "https://cal.com/ai",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@t3-oss/env-nextjs": "^0.6.1",
|
||||
"langchain": "^0.0.131",
|
||||
"mailparser": "^3.6.5",
|
||||
"next": "^13.4.6",
|
||||
"supports-color": "8.1.1",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mailparser": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev -p 3005",
|
||||
"format": "npx prettier . --write",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import agent from "../../../utils/agent";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Launches a LangChain agent to process an incoming email,
|
||||
* then sends the response to the user.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const json = await request.json();
|
||||
|
||||
const { apiKey, userId, message, subject, user, replyTo } = json;
|
||||
|
||||
if ((!message && !subject) || !user) {
|
||||
return new NextResponse("Missing fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await agent(`${subject}\n\n${message}`, user, apiKey, userId);
|
||||
|
||||
// Send response to user
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||
to: user.email,
|
||||
from: replyTo,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ParsedMail, Source } from "mailparser";
|
||||
import { simpleParser } from "mailparser";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||
import getHostFromHeaders from "../../../utils/host";
|
||||
import now from "../../../utils/now";
|
||||
import sendEmail from "../../../utils/sendEmail";
|
||||
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||
|
||||
/**
|
||||
* Verifies email signature and app authorization,
|
||||
* then hands off to booking agent.
|
||||
*/
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const verified = verifyParseKey(request.url);
|
||||
|
||||
if (!verified) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
timeZone: true,
|
||||
credentials: {
|
||||
select: {
|
||||
appId: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal AI! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for your interest in Cal AI! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||
|
||||
// User has not installed the app from the app store. Direct them to install it.
|
||||
if (!(credential as { apiKey: string })?.apiKey) {
|
||||
const url = env.APP_URL;
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal AI! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: `Thanks for using Cal AI! To get started, the app must be installed. Click this link to install the Cal AI app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("ok");
|
||||
}
|
||||
|
||||
const { apiKey } = credential as { apiKey: string };
|
||||
|
||||
// Pre-fetch data relevant to most bookings.
|
||||
const [eventTypes, availability] = await Promise.all([
|
||||
fetchEventTypes({
|
||||
apiKey,
|
||||
}),
|
||||
fetchAvailability({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
dateFrom: now(user.timeZone),
|
||||
dateTo: now(user.timeZone),
|
||||
}),
|
||||
]);
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(availability.error);
|
||||
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
});
|
||||
console.error(eventTypes.error);
|
||||
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||
}
|
||||
|
||||
const { workingHours } = availability;
|
||||
|
||||
const appHost = getHostFromHeaders(request.headers);
|
||||
|
||||
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text,
|
||||
subject: parsed.subject,
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
eventTypes,
|
||||
timeZone: user.timeZone,
|
||||
workingHours,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
return new NextResponse("ok");
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
BACKEND_URL: process.env.BACKEND_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
APP_URL: process.env.APP_URL,
|
||||
PARSE_KEY: process.env.PARSE_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BACKEND_URL: z.string().url(),
|
||||
APP_ID: z.string().min(1),
|
||||
APP_URL: z.string().url(),
|
||||
PARSE_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
OPENAI_API_KEY: z.string().min(1),
|
||||
SENDGRID_API_KEY: z.string().min(1),
|
||||
DATABASE_URL: z.string().url(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Creates a booking for a user by event type, times, and timezone.
|
||||
*/
|
||||
const createBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
eventTypeId,
|
||||
start,
|
||||
end,
|
||||
timeZone,
|
||||
language,
|
||||
responses,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
timeZone: string;
|
||||
language: string;
|
||||
responses: { name?: string; email?: string; location?: string };
|
||||
title?: string;
|
||||
status?: string;
|
||||
}): Promise<string | Error | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
metadata: {},
|
||||
responses,
|
||||
start,
|
||||
timeZone,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
error: data.message,
|
||||
};
|
||||
}
|
||||
|
||||
return "Booking created";
|
||||
};
|
||||
|
||||
const createBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description:
|
||||
"Tries to create a booking. If the user is unavailable, it will return availability that day, allowing you to avoid the getAvailability step in many cases.",
|
||||
func: async ({ eventTypeId, start, end, timeZone, language, responses, title, status }) => {
|
||||
return JSON.stringify(
|
||||
await createBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
end,
|
||||
eventTypeId,
|
||||
language,
|
||||
responses,
|
||||
start,
|
||||
status,
|
||||
timeZone,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "createBookingIfAvailable",
|
||||
schema: z.object({
|
||||
end: z
|
||||
.string()
|
||||
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||
eventTypeId: z.number(),
|
||||
language: z.string(),
|
||||
responses: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.describe("External invited user. Not the user making the request."),
|
||||
start: z.string(),
|
||||
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||
timeZone: z.string(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default createBookingTool;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Cancels a booking for a user by ID with reason.
|
||||
*/
|
||||
const cancelBooking = async ({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
}: {
|
||||
apiKey: string;
|
||||
id: string;
|
||||
reason: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ reason }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking cancelled";
|
||||
};
|
||||
|
||||
const cancelBookingTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Cancel a booking",
|
||||
func: async ({ id, reason }) => {
|
||||
return JSON.stringify(
|
||||
await cancelBooking({
|
||||
apiKey,
|
||||
id,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "cancelBooking",
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default cancelBookingTool;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Availability } from "../types/availability";
|
||||
|
||||
/**
|
||||
* Fetches availability for a user by date range and event type.
|
||||
*/
|
||||
export const fetchAvailability = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
eventTypeId?: number;
|
||||
}): Promise<Partial<Availability> | { error: string }> => {
|
||||
const params: { [k: string]: string } = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
};
|
||||
|
||||
if (eventTypeId) params["eventTypeId"] = eventTypeId.toString();
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return {
|
||||
busy: data.busy,
|
||||
dateRanges: data.dateRanges,
|
||||
timeZone: data.timeZone,
|
||||
workingHours: data.workingHours,
|
||||
};
|
||||
};
|
||||
|
||||
const getAvailabilityTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get availability within range.",
|
||||
func: async ({ dateFrom, dateTo, eventTypeId }) => {
|
||||
return JSON.stringify(
|
||||
await fetchAvailability({
|
||||
apiKey,
|
||||
userId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
eventTypeId,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getAvailability",
|
||||
schema: z.object({
|
||||
dateFrom: z.string(),
|
||||
dateTo: z.string(),
|
||||
eventTypeId: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"The ID of the event type to filter availability for if you've called getEventTypes, otherwise do not include."
|
||||
),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getAvailabilityTool;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { Booking } from "../types/booking";
|
||||
import { BOOKING_STATUS } from "../types/booking";
|
||||
|
||||
/**
|
||||
* Fetches bookings for a user by date range.
|
||||
*/
|
||||
const fetchBookings = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<Booking[] | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
const bookings: Booking[] = data.bookings
|
||||
.filter((booking: Booking) => {
|
||||
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||
|
||||
return afterFrom && beforeTo && notCancelled;
|
||||
})
|
||||
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||
endTime,
|
||||
eventTypeId,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
}));
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get bookings for a user between two dates.",
|
||||
func: async ({ from, to }) => {
|
||||
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||
},
|
||||
name: "getBookings",
|
||||
schema: z.object({
|
||||
from: z.string().describe("ISO 8601 datetime string"),
|
||||
to: z.string().describe("ISO 8601 datetime string"),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getBookingsTool;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import type { EventType } from "../types/eventType";
|
||||
|
||||
/**
|
||||
* Fetches event types by user ID.
|
||||
*/
|
||||
export const fetchEventTypes = async ({ apiKey }: { apiKey: string }) => {
|
||||
const params = {
|
||||
apiKey,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return data.event_types.map((eventType: EventType) => ({
|
||||
id: eventType.id,
|
||||
length: eventType.length,
|
||||
title: eventType.title,
|
||||
}));
|
||||
};
|
||||
|
||||
const getEventTypesTool = (apiKey: string) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Get the user's event type IDs. Usually necessary to book a meeting.",
|
||||
func: async () => {
|
||||
return JSON.stringify(
|
||||
await fetchEventTypes({
|
||||
apiKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "getEventTypes",
|
||||
schema: z.object({}),
|
||||
});
|
||||
};
|
||||
|
||||
export default getEventTypesTool;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DynamicStructuredTool } from "langchain/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||
*/
|
||||
const editBooking = async ({
|
||||
apiKey,
|
||||
userId,
|
||||
id,
|
||||
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||
endTime, // Same here: it says end but it's endTime.
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
apiKey: string;
|
||||
userId: number;
|
||||
id: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
}): Promise<string | { error: string }> => {
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
};
|
||||
const urlParams = new URLSearchParams(params);
|
||||
|
||||
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
|
||||
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||
// if (response.status === 401) throw new Error("Unauthorized");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
return { error: data.message };
|
||||
}
|
||||
|
||||
return "Booking edited";
|
||||
};
|
||||
|
||||
const editBookingTool = (apiKey: string, userId: number) => {
|
||||
return new DynamicStructuredTool({
|
||||
description: "Edit a booking",
|
||||
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||
return JSON.stringify(
|
||||
await editBooking({
|
||||
apiKey,
|
||||
userId,
|
||||
description,
|
||||
endTime,
|
||||
id,
|
||||
startTime,
|
||||
status,
|
||||
title,
|
||||
})
|
||||
);
|
||||
},
|
||||
name: "editBooking",
|
||||
schema: z.object({
|
||||
description: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
id: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default editBookingTool;
|
||||
@@ -0,0 +1,25 @@
|
||||
export type Availability = {
|
||||
busy: {
|
||||
start: string;
|
||||
end: string;
|
||||
title?: string;
|
||||
}[];
|
||||
timeZone: string;
|
||||
dateRanges: {
|
||||
start: string;
|
||||
end: string;
|
||||
}[];
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
}[];
|
||||
dateOverrides: {
|
||||
date: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
userId: number;
|
||||
};
|
||||
currentSeats: number;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export enum BOOKING_STATUS {
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
export type Booking = {
|
||||
id: number;
|
||||
userId: number;
|
||||
description: string | null;
|
||||
eventTypeId: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||
metadata: object | null;
|
||||
status: BOOKING_STATUS;
|
||||
responses: { email: string; name: string; location: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export type EventType = {
|
||||
id: number;
|
||||
title: string;
|
||||
length: number;
|
||||
metadata: object;
|
||||
slug: string;
|
||||
hosts: {
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
}[];
|
||||
hidden: boolean;
|
||||
// ...
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EventType } from "./eventType";
|
||||
import type { WorkingHours } from "./workingHours";
|
||||
|
||||
export type User = {
|
||||
email: string;
|
||||
timeZone: string;
|
||||
eventTypes: EventType[];
|
||||
workingHours: WorkingHours[];
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type WorkingHours = {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import createBookingIfAvailable from "../tools/createBookingIfAvailable";
|
||||
import deleteBooking from "../tools/deleteBooking";
|
||||
import getAvailability from "../tools/getAvailability";
|
||||
import getBookings from "../tools/getBookings";
|
||||
import updateBooking from "../tools/updateBooking";
|
||||
import type { EventType } from "../types/eventType";
|
||||
import type { User } from "../types/user";
|
||||
import type { WorkingHours } from "../types/workingHours";
|
||||
import now from "./now";
|
||||
|
||||
const gptModel = "gpt-4";
|
||||
|
||||
/**
|
||||
* Core of the Cal AI booking agent: a LangChain Agent Executor.
|
||||
* Uses a toolchain to book meetings, list available slots, etc.
|
||||
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||
*/
|
||||
const agent = async (input: string, user: User, apiKey: string, userId: number) => {
|
||||
const tools = [
|
||||
createBookingIfAvailable(apiKey, userId),
|
||||
getAvailability(apiKey, userId),
|
||||
getBookings(apiKey, userId),
|
||||
updateBooking(apiKey, userId),
|
||||
deleteBooking(apiKey),
|
||||
];
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
modelName: gptModel,
|
||||
openAIApiKey: env.OPENAI_API_KEY,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the agent executor with arguments.
|
||||
*/
|
||||
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||
agentArgs: {
|
||||
prefix: `You are Cal AI - a bleeding edge scheduling assistant that interfaces via email.
|
||||
Make sure your final answers are definitive, complete and well formatted.
|
||||
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||
Tools will always handle times in UTC, but times sent to the user should be formatted per that user's timezone.
|
||||
|
||||
The current time in the user's timezone is: ${now(user.timeZone)}
|
||||
The user's time zone is: ${user.timeZone}
|
||||
The user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Title: ${e.title}, Length: ${e.length}`)
|
||||
.join("\n")}
|
||||
The user's working hours are: ${user.workingHours
|
||||
.map(
|
||||
(w: WorkingHours) =>
|
||||
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||
w.startTime
|
||||
}, End Time (minutes in UTC): ${w.endTime}`
|
||||
)
|
||||
.join("\n")}
|
||||
`,
|
||||
},
|
||||
agentType: "openai-functions",
|
||||
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||
verbose: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const result = await executor.call({ input });
|
||||
const { output } = result;
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export default agent;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||
return `https://${headers.get("host")}`;
|
||||
};
|
||||
|
||||
export default getHostFromHeaders;
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function now(timeZone: string) {
|
||||
return new Date().toLocaleString("en-US", {
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import mail from "@sendgrid/mail";
|
||||
|
||||
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||
|
||||
/**
|
||||
* Simply send an email by address, subject, and body.
|
||||
*/
|
||||
const send = async ({
|
||||
subject,
|
||||
to,
|
||||
from,
|
||||
text,
|
||||
html,
|
||||
}: {
|
||||
subject: string;
|
||||
to: string;
|
||||
from: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}): Promise<boolean> => {
|
||||
mail.setApiKey(sendgridAPIKey);
|
||||
|
||||
const msg = {
|
||||
to,
|
||||
from: {
|
||||
email: from,
|
||||
name: "Cal AI",
|
||||
},
|
||||
text,
|
||||
html,
|
||||
subject,
|
||||
};
|
||||
|
||||
const res = await mail.send(msg);
|
||||
const success = !!res;
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
export default send;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
/**
|
||||
* Verifies that the request contains the correct parse key.
|
||||
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||
*/
|
||||
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||
|
||||
return verified;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
|
||||
@@ -17,7 +18,7 @@ export const dateNotInPast = function (date: Date) {
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const { prisma, isCustomPrisma, isAdmin } = req;
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && process.env.NODE_ENV !== "development")
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
// If the user is an admin and using a license key (from customPrisma), skip the apiKey check.
|
||||
if (isCustomPrisma && isAdmin) {
|
||||
|
||||
@@ -33,12 +33,14 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
position: true,
|
||||
eventName: true,
|
||||
timeZone: true,
|
||||
schedulingType: true,
|
||||
// START Limit future bookings
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
schedulingType: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
// END Limit future bookings
|
||||
requiresConfirmation: true,
|
||||
disableGuests: true,
|
||||
hideCalendarNotes: true,
|
||||
@@ -51,6 +53,8 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||
slotInterval: true,
|
||||
successRedirectUrl: true,
|
||||
locations: true,
|
||||
bookingLimits: true,
|
||||
durationLimits: true,
|
||||
})
|
||||
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
|
||||
.partial()
|
||||
@@ -67,6 +71,7 @@ const schemaEventTypeCreateParams = z
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -85,6 +90,7 @@ const schemaEventTypeEditParams = z
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
scheduleId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -98,6 +104,7 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
position: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
scheduleId: true,
|
||||
eventName: true,
|
||||
timeZone: true,
|
||||
periodType: true,
|
||||
@@ -123,6 +130,8 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
bookingFields: true,
|
||||
bookingLimits: true,
|
||||
durationLimits: true,
|
||||
}).merge(
|
||||
z.object({
|
||||
locations: z
|
||||
|
||||
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export const membershipCreateBodySchema = Membership.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
}).transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||
.partial({
|
||||
accepted: true,
|
||||
role: true,
|
||||
disableImpersonation: true,
|
||||
})
|
||||
.transform((v) => ({
|
||||
accepted: false,
|
||||
role: MembershipRole.MEMBER,
|
||||
disableImpersonation: false,
|
||||
...v,
|
||||
}));
|
||||
|
||||
export const membershipEditBodySchema = Membership.omit({
|
||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { withValidation } from "next-validations";
|
||||
import { z } from "zod";
|
||||
|
||||
import { baseApiParams } from "./baseApiParams";
|
||||
|
||||
// Extracted out as utility function so can be reused
|
||||
// at different endpoints that require this validation.
|
||||
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||
email: z.union([z.string().email(), z.array(z.string().email())]),
|
||||
});
|
||||
|
||||
export const withValidQueryUserEmail = withValidation({
|
||||
schema: schemaQueryUserEmail,
|
||||
type: "Zod",
|
||||
mode: "query",
|
||||
});
|
||||
@@ -29,7 +29,7 @@ enum locales {
|
||||
RO = "ro",
|
||||
NL = "nl",
|
||||
PT_BR = "pt-BR",
|
||||
ES_419 = "es-419",
|
||||
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
|
||||
KO = "ko",
|
||||
JA = "ja",
|
||||
PL = "pl",
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
"typescript": "^4.9.4",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.20.2"
|
||||
"zod": "^3.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
import { checkPermissions as canAccessTeamEventOrThrow } from "~/pages/api/teams/[teamId]/_auth-middleware";
|
||||
|
||||
import getCalLink from "../_utils/getCalLink";
|
||||
|
||||
@@ -42,7 +44,8 @@ import getCalLink from "../_utils/getCalLink";
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { prisma, query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const event_type = await prisma.eventType.findUnique({
|
||||
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customInputs: true,
|
||||
@@ -51,10 +54,43 @@ export async function getHandler(req: NextApiRequest) {
|
||||
owner: { select: { username: true, id: true } },
|
||||
},
|
||||
});
|
||||
await checkPermissions(req, eventType);
|
||||
|
||||
const link = event_type ? getCalLink(event_type) : null;
|
||||
const link = eventType ? getCalLink(eventType) : null;
|
||||
// user.defaultScheduleId doesn't work the same for team events.
|
||||
if (!eventType?.scheduleId && eventType?.userId && !eventType?.teamId) {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventType.userId,
|
||||
},
|
||||
select: {
|
||||
defaultScheduleId: true,
|
||||
},
|
||||
});
|
||||
eventType.scheduleId = user.defaultScheduleId;
|
||||
}
|
||||
|
||||
return { event_type: schemaEventTypeReadPublic.parse({ ...event_type, link }) };
|
||||
// TODO: eventType when not found should be a 404
|
||||
// but API consumers may depend on the {} behaviour.
|
||||
return { event_type: schemaEventTypeReadPublic.parse({ ...eventType, link }) };
|
||||
}
|
||||
|
||||
type BaseEventTypeCheckPermissions = {
|
||||
userId: number | null;
|
||||
teamId: number | null;
|
||||
};
|
||||
|
||||
async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
|
||||
req: NextApiRequest,
|
||||
eventType: (T & Partial<Omit<T, keyof BaseEventTypeCheckPermissions>>) | null
|
||||
) {
|
||||
if (req.isAdmin) return true;
|
||||
if (eventType?.teamId) {
|
||||
req.query.teamId = String(eventType.teamId);
|
||||
await canAccessTeamEventOrThrow(req, "MEMBER");
|
||||
}
|
||||
if (eventType?.userId === req.userId) return true; // is owner.
|
||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { z } from "zod";
|
||||
|
||||
@@ -52,6 +52,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
||||
* slug:
|
||||
* type: string
|
||||
* description: Unique slug for the event type
|
||||
* scheduleId:
|
||||
* type: number
|
||||
* description: The ID of the schedule for this event type
|
||||
* hosts:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -199,10 +202,17 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
||||
export async function patchHandler(req: NextApiRequest) {
|
||||
const { prisma, query, body } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body);
|
||||
const {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
...parsedBody
|
||||
} = schemaEventTypeEditBodyParams.parse(body);
|
||||
|
||||
const data: Prisma.EventTypeUpdateArgs["data"] = {
|
||||
...parsedBody,
|
||||
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||
};
|
||||
|
||||
if (hosts) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||
@@ -35,20 +35,12 @@ import getCalLink from "./_utils/getCalLink";
|
||||
* description: No event types were found
|
||||
*/
|
||||
async function getHandler(req: NextApiRequest) {
|
||||
const { userId, isAdmin, prisma } = req;
|
||||
|
||||
const args: Prisma.EventTypeFindManyArgs = {
|
||||
where: { userId },
|
||||
};
|
||||
/** Only admins can query other users */
|
||||
if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
||||
if (isAdmin && req.query.userId) {
|
||||
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
|
||||
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
|
||||
args.where = { userId: { in: userIds } };
|
||||
}
|
||||
const { userId, prisma } = req;
|
||||
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||
const data = await prisma.eventType.findMany({
|
||||
...args,
|
||||
where: {
|
||||
userId: { in: userIds },
|
||||
},
|
||||
include: {
|
||||
customInputs: true,
|
||||
team: { select: { slug: true } },
|
||||
@@ -56,13 +48,74 @@ async function getHandler(req: NextApiRequest) {
|
||||
owner: { select: { username: true, id: true } },
|
||||
},
|
||||
});
|
||||
// this really should return [], but backwards compatibility..
|
||||
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
|
||||
return {
|
||||
event_types: data.map((eventType) => {
|
||||
const link = getCalLink(eventType);
|
||||
return schemaEventTypeReadPublic.parse({ ...eventType, link });
|
||||
}),
|
||||
event_types: (await defaultScheduleId<(typeof data)[number]>({ eventTypes: data, prisma, userIds })).map(
|
||||
(eventType) => {
|
||||
const link = getCalLink(eventType);
|
||||
return schemaEventTypeReadPublic.parse({ ...eventType, link });
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
// TODO: Extract & reuse.
|
||||
function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
|
||||
/** Guard: Only admins can query other users */
|
||||
if (!isAdmin) {
|
||||
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
||||
}
|
||||
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
|
||||
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
|
||||
}
|
||||
|
||||
type DefaultScheduleIdEventTypeBase = {
|
||||
scheduleId: number | null;
|
||||
userId: number | null;
|
||||
};
|
||||
// If an eventType is given w/o a scheduleId
|
||||
// Then we associate the default user schedule id to the eventType
|
||||
async function defaultScheduleId<T extends DefaultScheduleIdEventTypeBase>({
|
||||
prisma,
|
||||
eventTypes,
|
||||
userIds,
|
||||
}: {
|
||||
prisma: PrismaClient;
|
||||
eventTypes: (T & Partial<Omit<T, keyof DefaultScheduleIdEventTypeBase>>)[];
|
||||
userIds: number[];
|
||||
}) {
|
||||
// there is no event types without a scheduleId, skip the user query
|
||||
if (eventTypes.every((eventType) => eventType.scheduleId)) return eventTypes;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: userIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
defaultScheduleId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) {
|
||||
return eventTypes;
|
||||
}
|
||||
|
||||
const defaultScheduleIds = users.reduce((result, user) => {
|
||||
result[user.id] = user.defaultScheduleId;
|
||||
return result;
|
||||
}, {} as { [x: number]: number | null });
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
// realistically never happens, userId should't be null on personal event types.
|
||||
if (!eventType.userId) return eventType;
|
||||
return {
|
||||
...eventType,
|
||||
scheduleId: eventType.scheduleId || defaultScheduleIds[eventType.userId],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -60,6 +60,9 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* hidden:
|
||||
* type: boolean
|
||||
* description: If the event type should be hidden from your public booking page
|
||||
* scheduleId:
|
||||
* type: number
|
||||
* description: The ID of the schedule for this event type
|
||||
* position:
|
||||
* type: integer
|
||||
* description: The position of the event type on the public booking page
|
||||
@@ -178,6 +181,7 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* position: 0
|
||||
* eventName: null
|
||||
* timeZone: null
|
||||
* scheduleId: 5
|
||||
* periodType: UNLIMITED
|
||||
* periodStartDate: 2023-02-15T08:46:16.000Z
|
||||
* periodEndDate: 2023-0-15T08:46:16.000Z
|
||||
@@ -255,12 +259,19 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const { userId, isAdmin, prisma, body } = req;
|
||||
|
||||
const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||
const {
|
||||
hosts = [],
|
||||
bookingLimits,
|
||||
durationLimits,
|
||||
...parsedBody
|
||||
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||
|
||||
let data: Prisma.EventTypeCreateArgs["data"] = {
|
||||
...parsedBody,
|
||||
userId,
|
||||
users: { connect: { id: userId } },
|
||||
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||
};
|
||||
|
||||
await checkPermissions(req);
|
||||
|
||||
@@ -59,7 +59,7 @@ async function getHandler(req: NextApiRequest) {
|
||||
};
|
||||
|
||||
const data = await prisma.eventType.findMany(args);
|
||||
return { event_types: data.map((attendee) => schemaEventTypeReadPublic.parse(attendee)) };
|
||||
return { event_types: data.map((eventType) => schemaEventTypeReadPublic.parse(eventType)) };
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NextApiRequest } from "next";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||
import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail";
|
||||
import { schemaUsersReadPublic } from "~/lib/validations/user";
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,17 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Your API key
|
||||
* - in: query
|
||||
* name: email
|
||||
* required: false
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: email
|
||||
* style: form
|
||||
* explode: true
|
||||
* description: The email address or an array of email addresses to filter by
|
||||
* tags:
|
||||
* - users
|
||||
* responses:
|
||||
@@ -39,8 +51,16 @@ export async function getHandler(req: NextApiRequest) {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
// If user is not ADMIN, return only his data.
|
||||
if (!isAdmin) where.id = userId;
|
||||
|
||||
if (req.query.email) {
|
||||
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
|
||||
where.email = {
|
||||
in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email],
|
||||
};
|
||||
}
|
||||
|
||||
const [total, data] = await prisma.$transaction([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
]);
|
||||
const users = schemaUsersReadPublic.parse(data);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Set the version of docker compose to use
|
||||
version: '3.9'
|
||||
version: "3.9"
|
||||
|
||||
# The containers that compose the project
|
||||
services:
|
||||
@@ -8,8 +8,8 @@ services:
|
||||
restart: always
|
||||
container_name: integration-tests-prisma
|
||||
ports:
|
||||
- '5433:5432'
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_USER: prisma
|
||||
POSTGRES_PASSWORD: prisma
|
||||
POSTGRES_DB: tests
|
||||
POSTGRES_DB: tests
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
const ns = ["common"];
|
||||
const supportedLngs = ["en", "fr"];
|
||||
const resources = ns.reduce((acc, n) => {
|
||||
supportedLngs.forEach((lng) => {
|
||||
if (!acc[lng]) acc[lng] = {};
|
||||
acc[lng] = {
|
||||
...acc[lng],
|
||||
[n]: require(`../../web/public/static/locales/${lng}/${n}.json`),
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: [],
|
||||
debug: true,
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
ns,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: { useSuspense: true },
|
||||
resources,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -15,6 +15,7 @@ module.exports = {
|
||||
"storybook-addon-rtl-direction",
|
||||
"storybook-react-i18next",
|
||||
"storybook-addon-next",
|
||||
"storybook-addon-next-router",
|
||||
/*{
|
||||
name: "storybook-addon-next",
|
||||
options: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script>
|
||||
window.global = window;
|
||||
window.isEmbed = ()=> {
|
||||
return location.search.includes("embed=")
|
||||
}
|
||||
window.isEmbed = () => {
|
||||
return location.search.includes("embed=");
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap');
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap");
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addDecorator } from "@storybook/react";
|
||||
import { AppRouterContext } from "next/dist/shared/lib/app-router-context";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import "../styles/globals.css";
|
||||
@@ -13,6 +14,21 @@ export const parameters = {
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
nextRouter: {
|
||||
pathname: "/",
|
||||
asPath: "/",
|
||||
query: {},
|
||||
push() {},
|
||||
Provider: AppRouterContext.Provider,
|
||||
},
|
||||
globals: {
|
||||
locale: "en",
|
||||
locales: {
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
},
|
||||
},
|
||||
i18n,
|
||||
};
|
||||
|
||||
addDecorator((storyFn) => (
|
||||
|
||||
@@ -18,6 +18,7 @@ export function VariantsTable({
|
||||
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
|
||||
return (
|
||||
<div
|
||||
id="light-variant"
|
||||
className={classNames(
|
||||
isDark &&
|
||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
||||
@@ -43,7 +44,7 @@ export function VariantsTable({
|
||||
</table>
|
||||
</div>
|
||||
{!isDark && (
|
||||
<div data-mode="dark" className="dark">
|
||||
<div id="dark-variant" data-mode="dark" className="dark">
|
||||
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
||||
{children}
|
||||
</VariantsTable>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Meta } from '@storybook/addon-docs';
|
||||
import { Meta } from "@storybook/addon-docs";
|
||||
|
||||
<Meta title="Introduction" />
|
||||
|
||||
<div className="text-center flex flex-col items-center">
|
||||
<h1 style={{marginBottom: '24px', marginTop: '36px'}}>Welcome to Cal.com UI</h1>
|
||||
<p>This is the beginning of our storybook improvements to match Figma as close as possible. Like our Figma library, we will be adding more components as we go along.</p>
|
||||
<p>Our <a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">Figma</a> library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to the design team.</p>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 style={{ marginBottom: "24px", marginTop: "36px" }}>Welcome to Cal.com UI</h1>
|
||||
<p>
|
||||
This is the beginning of our storybook improvements to match Figma as close as possible. Like our Figma
|
||||
library, we will be adding more components as we go along.
|
||||
</p>
|
||||
<p>
|
||||
Our{" "}
|
||||
<a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">
|
||||
Figma
|
||||
</a>{" "}
|
||||
library is available for anyone to view and use. If you have any questions or concerns, please reach out to
|
||||
the design team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<img src="/sb-cover.jpg" />
|
||||
<img src="/sb-cover.jpg" />
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"next": "^13.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"storybook-addon-next-router": "^4.0.2",
|
||||
"storybook-addon-rtl-direction": "^0.0.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
||||
|
||||
.sbdocs {
|
||||
font-family: 'Inter var' !important;
|
||||
padding: 0!important;
|
||||
font-family: "Inter var" !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#docs-root {
|
||||
@@ -21,8 +21,8 @@
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-top: 2px solid rgba(0, 0, 0, 0.12);
|
||||
padding-top: 12px!important;
|
||||
padding-bottom: 12px!important;
|
||||
padding-top: 12px !important;
|
||||
padding-bottom: 12px !important;
|
||||
margin: 82px 0 0 0;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
/** Docs table **/
|
||||
.custom-args-wrapper{
|
||||
.custom-args-wrapper {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
@@ -45,7 +45,7 @@
|
||||
}
|
||||
|
||||
.docblock-argstable-body {
|
||||
box-shadow: none!important;
|
||||
box-shadow: none !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
.docblock-argstable-body div p,
|
||||
.docblock-argstable-body div span {
|
||||
color: #8F8F8F !important;
|
||||
color: #8f8f8f !important;
|
||||
}
|
||||
|
||||
/** Custom components **/
|
||||
@@ -83,8 +83,8 @@
|
||||
}
|
||||
|
||||
.story-title h1 span {
|
||||
color: #9CA3AF;
|
||||
font-family: 'Inter var';
|
||||
color: #9ca3af;
|
||||
font-family: "Inter var";
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
.examples {
|
||||
background-color: #F9FAFB;
|
||||
background-color: #f9fafb;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -111,14 +111,14 @@
|
||||
}
|
||||
|
||||
.examples-title {
|
||||
color: #8F8F8F;
|
||||
color: #8f8f8f;
|
||||
font-size: 14px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.examples-item-title {
|
||||
font-size: 12px;
|
||||
color: #8F8F8F;
|
||||
color: #8f8f8f;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
.examples-footnote p,
|
||||
.examples-footnote ul,
|
||||
.examples-footnote li {
|
||||
color: #8F8F8F;
|
||||
color: #8f8f8f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@
|
||||
}
|
||||
|
||||
.examples-footnote li {
|
||||
margin: 0!important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.story-note {
|
||||
background-color: #F9FAFB;
|
||||
background-color: #f9fafb;
|
||||
font-size: 14px;
|
||||
padding: 20px;
|
||||
margin-bottom: 12px;
|
||||
@@ -161,12 +161,12 @@
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: none!important;
|
||||
border-radius: none !important;
|
||||
}
|
||||
|
||||
.docs-story > div:first-child {
|
||||
padding: 0!important;
|
||||
margin: 0!important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.docs-story .innerZoomElementWrapper > div {
|
||||
@@ -174,7 +174,7 @@
|
||||
}
|
||||
|
||||
.sb-main-padded {
|
||||
padding: 0!important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
@@ -198,83 +198,81 @@
|
||||
:root {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #E5E7EB;
|
||||
--cal-bg-emphasis: #e5e7eb;
|
||||
--cal-bg: white;
|
||||
--cal-bg-subtle: #F3F4F6;
|
||||
--cal-bg-muted: #F9FAFB;
|
||||
--cal-bg-subtle: #f3f4f6;
|
||||
--cal-bg-muted: #f9fafb;
|
||||
--cal-bg-inverted: #111827;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #DEE9FC;
|
||||
--cal-bg-success: #E2FBE8;
|
||||
--cal-bg-attention: #FCEED8;
|
||||
--cal-bg-error: #F9E3E2;
|
||||
--cal-bg-info: #dee9fc;
|
||||
--cal-bg-success: #e2fbe8;
|
||||
--cal-bg-attention: #fceed8;
|
||||
--cal-bg-error: #f9e3e2;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis:#9CA3AF;
|
||||
--cal-border: #D1D5DB;
|
||||
--cal-border-subtle:#E5E7EB;
|
||||
--cal-border-muted:#F3F4F6;
|
||||
--cal-border-emphasis: #9ca3af;
|
||||
--cal-border: #d1d5db;
|
||||
--cal-border-subtle: #e5e7eb;
|
||||
--cal-border-muted: #f3f4f6;
|
||||
--cal-border-error: #aa2e26;
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #111827;
|
||||
--cal-text:#374151;
|
||||
--cal-text-subtle:#6B7280;
|
||||
--cal-text-muted:#9CA3AF;
|
||||
--cal-text-inverted:white;
|
||||
--cal-text: #374151;
|
||||
--cal-text-subtle: #6b7280;
|
||||
--cal-text-muted: #9ca3af;
|
||||
--cal-text-inverted: white;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info:#253985;
|
||||
--cal-text-success:#285231;
|
||||
--cal-text-attention:#73321B;
|
||||
--cal-text-error:#752522;
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321b;
|
||||
--cal-text-error: #752522;
|
||||
|
||||
/* Brand shinanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
*/
|
||||
--cal-brand:#111827;
|
||||
--cal-brand-emphasis:#101010;
|
||||
--cal-brand: #111827;
|
||||
--cal-brand-emphasis: #101010;
|
||||
--cal-brand-text: white;
|
||||
}
|
||||
.dark {
|
||||
/* background */
|
||||
|
||||
--cal-bg-emphasis: #2B2B2B;
|
||||
--cal-bg-emphasis: #2b2b2b;
|
||||
--cal-bg: #101010;
|
||||
--cal-bg-subtle: #2B2B2B;
|
||||
--cal-bg-muted: #1C1C1C;
|
||||
--cal-bg-inverted: #F3F4F6;
|
||||
--cal-bg-subtle: #2b2b2b;
|
||||
--cal-bg-muted: #1c1c1c;
|
||||
--cal-bg-inverted: #f3f4f6;
|
||||
|
||||
/* background -> components*/
|
||||
--cal-bg-info: #DEE9FC;
|
||||
--cal-bg-success: #E2FBE8;
|
||||
--cal-bg-attention: #FCEED8;
|
||||
--cal-bg-error: #F9E3E2;
|
||||
--cal-bg-info: #263fa9;
|
||||
--cal-bg-success: #306339;
|
||||
--cal-bg-attention: #8e3b1f;
|
||||
--cal-bg-error: #8c2822;
|
||||
--cal-bg-dark-error: #752522;
|
||||
|
||||
|
||||
/* Borders */
|
||||
--cal-border-emphasis: #575757;
|
||||
--cal-border: #444444;
|
||||
--cal-border-subtle: #2B2B2B;
|
||||
--cal-border-muted: #1C1C1C;
|
||||
--cal-border-subtle: #2b2b2b;
|
||||
--cal-border-muted: #1c1c1c;
|
||||
--cal-border-error: #aa2e26;
|
||||
|
||||
/* Content/Text */
|
||||
--cal-text-emphasis: #F3F4F6;
|
||||
--cal-text: #D6D6D6;
|
||||
--cal-text-emphasis: #f3f4f6;
|
||||
--cal-text: #d6d6d6;
|
||||
--cal-text-subtle: #757575;
|
||||
--cal-text-muted: #575757;
|
||||
--cal-text-inverted: #101010;
|
||||
|
||||
/* Content/Text -> components */
|
||||
--cal-text-info: #253985;
|
||||
--cal-text-success: #285231;
|
||||
--cal-text-attention: #73321B;
|
||||
--cal-text-error: #752522;
|
||||
--cal-text-info: #dee9fc;
|
||||
--cal-text-success: #e2fbe8;
|
||||
--cal-text-attention: #fceed8;
|
||||
--cal-text-error: #f9e3e2;
|
||||
|
||||
/* Brand shenanigans
|
||||
-> These will be computed for the users theme at runtime.
|
||||
|
||||
@@ -2,8 +2,8 @@ html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans,
|
||||
Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -21,7 +21,7 @@ a {
|
||||
}
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
display: grid;
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
.opblock-summary-path {
|
||||
flex-shrink: 0;
|
||||
@@ -43,22 +43,22 @@ a {
|
||||
font-size: 22px;
|
||||
}
|
||||
.swagger-ui .scheme-container {
|
||||
padding: 14px 0;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.swagger-ui .info {
|
||||
margin: 10px 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .auth-wrapper {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .authorization__btn {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .opblock {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
button.opblock-summary-control > svg {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .filter .operation-filter-input {
|
||||
border: 2px solid #d8dde7;
|
||||
@@ -73,15 +73,16 @@ a {
|
||||
.swagger-ui .info .title small {
|
||||
top: 5px;
|
||||
}
|
||||
.swagger-ui a.nostyle, .swagger-ui a.nostyle:visited {
|
||||
.swagger-ui a.nostyle,
|
||||
.swagger-ui a.nostyle:visited {
|
||||
width: 100%;
|
||||
}
|
||||
div.request-snippets > div.curl-command > div:nth-child(1) {
|
||||
overscroll-behavior: contain;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.swagger-ui .opblock-body pre.microlight {
|
||||
font-size: 9px;
|
||||
.swagger-ui .opblock-body pre.microlight {
|
||||
font-size: 9px;
|
||||
}
|
||||
.swagger-ui table tbody tr td {
|
||||
padding: 0px 0 0;
|
||||
@@ -91,7 +92,7 @@ a {
|
||||
font-size: 12px;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
||||
display: flex;
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
font-size: 60%;
|
||||
@@ -99,4 +100,4 @@ a {
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
@@ -80,7 +81,13 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
return (
|
||||
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
|
||||
<div className="flex items-center gap-x-3 px-5 py-4">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
{logo ? (
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "h-10 w-10")}
|
||||
src={logo}
|
||||
alt={`${title} logo`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="text-emphasis truncate text-sm font-semibold">{title}</h3>
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
import { lookup } from "bcp-47-match";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CALCOM_VERSION } from "@calcom/lib/constants";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useViewerI18n() {
|
||||
return trpc.viewer.public.i18n.useQuery(undefined, {
|
||||
staleTime: Infinity,
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
* We intend to not cache i18n query
|
||||
**/
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
});
|
||||
function useViewerI18n(locale: string) {
|
||||
return trpc.viewer.public.i18n.useQuery(
|
||||
{ locale, CalComVersion: CALCOM_VERSION },
|
||||
{
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function useClientLocale(locales: string[]) {
|
||||
const session = useSession();
|
||||
// If the user is logged in, use their locale
|
||||
if (session.data?.user.locale) return session.data.user.locale;
|
||||
// If the user is not logged in, use the browser locale
|
||||
if (typeof window !== "undefined") {
|
||||
// This is the only way I found to ensure the prefetched locale is used on first render
|
||||
// FIXME: Find a better way to pick the best matching locale from the browser
|
||||
return lookup(locales, window.navigator.language) || window.navigator.language;
|
||||
}
|
||||
// If the browser is not available, use English
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function useClientViewerI18n(locales: string[]) {
|
||||
const clientLocale = useClientLocale(locales);
|
||||
return useViewerI18n(clientLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-switches locale client-side to the logged in user's preference
|
||||
*/
|
||||
const I18nLanguageHandler = () => {
|
||||
const I18nLanguageHandler = (props: { locales: string[] }) => {
|
||||
const { locales } = props;
|
||||
const { i18n } = useTranslation("common");
|
||||
const locale = useViewerI18n().data?.locale || i18n.language;
|
||||
const locale = useClientViewerI18n(locales).data?.locale || i18n.language;
|
||||
|
||||
useEffect(() => {
|
||||
// bail early when i18n = {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Script from "next/script";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { WEBAPP_URL, IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
@@ -72,7 +72,7 @@ function PageWrapper(props: AppProps) {
|
||||
}
|
||||
{...seoConfig.defaultNextSeo}
|
||||
/>
|
||||
<I18nLanguageHandler />
|
||||
<I18nLanguageHandler locales={props.router.locales || []} />
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
|
||||
@@ -112,7 +112,7 @@ export const InstallAppButtonChild = ({
|
||||
);
|
||||
}}>
|
||||
<p>
|
||||
{team.name} {isInstalled && `(${t("installed")})`}
|
||||
{t(team.name)} {isInstalled && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
@@ -12,7 +12,7 @@ type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
@@ -22,7 +22,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
@@ -38,7 +38,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, TextField } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
|
||||
<Label className="mt-4">{t("backup_code")}</Label>
|
||||
|
||||
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
|
||||
|
||||
<TextField
|
||||
id="backup-code"
|
||||
label=""
|
||||
defaultValue=""
|
||||
placeholder="XXXXX-XXXXX"
|
||||
minLength={10} // without dash
|
||||
maxLength={11} // with dash
|
||||
required
|
||||
{...methods.register("backupCode")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Input } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
export default function TwoFactor({ center = true, autoFocus = true }) {
|
||||
const [value, onChange] = useState("");
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
|
||||
name={`2fa${index + 1}`}
|
||||
inputMode="decimal"
|
||||
{...digit}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -336,7 +336,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
||||
<td
|
||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
@@ -368,7 +368,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{t("error_collecting_card")}
|
||||
</Badge>
|
||||
) : booking.paid ? (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
|
||||
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button data-testid="update-location" type="submit">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<Clock className="m-auto h-6 w-6" />
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { Copy, Edit } from "@calcom/ui/components/icon";
|
||||
import { IS_VISUAL_REGRESSION_TESTING } from "@calcom/web/constants";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
@@ -286,36 +287,38 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}}>
|
||||
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||
<div className="lg:-ml-2">
|
||||
<TextField
|
||||
disabled
|
||||
name="hashedLink"
|
||||
label={t("private_link_label")}
|
||||
data-testid="generated-hash-url"
|
||||
labelSrOnly
|
||||
type="text"
|
||||
hint={t("private_link_hint")}
|
||||
defaultValue={placeholderHashedLink}
|
||||
addOnSuffix={
|
||||
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
name="hashedLink"
|
||||
label={t("private_link_label")}
|
||||
data-testid="generated-hash-url"
|
||||
labelSrOnly
|
||||
type="text"
|
||||
hint={t("private_link_hint")}
|
||||
defaultValue={placeholderHashedLink}
|
||||
addOnSuffix={
|
||||
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
<hr className="border-subtle" />
|
||||
@@ -338,6 +341,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
|
||||
@@ -138,7 +138,7 @@ const EventTypeScheduleDetails = memo(
|
||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
{!!schedule?.id && !schedule.isManaged && (
|
||||
{!!schedule?.id && !schedule.isManaged && !schedule.readOnly && (
|
||||
<Button
|
||||
href={`/availability/${schedule.id}`}
|
||||
disabled={isLoading}
|
||||
@@ -202,6 +202,15 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
// We push the selected schedule from the event type if it's not part of the list response. This happens if the user is an admin but not the schedule owner.
|
||||
else if (eventType.schedule && !schedules.find((schedule) => schedule.id === eventType.schedule)) {
|
||||
options.push({
|
||||
value: eventType.schedule,
|
||||
label: eventType.scheduleName ?? t("default_schedule_name"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
setOptions(options);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
@@ -462,7 +463,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
defaultValue={eventType.offsetStart}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
@@ -477,11 +477,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
|
||||
const INTERVAL_LIMIT_OPTIONS = ascendingLimitKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
|
||||
label: `Per ${intervalLimitKeyToUnit(key)}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
@@ -590,8 +588,8 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { z } from "zod";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -116,6 +117,8 @@ export const EventSetupTab = (
|
||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
||||
const orgBranding = useOrgBranding();
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const locationOptions = props.locationOptions.map((locationOption) => {
|
||||
const options = locationOption.options.filter((option) => {
|
||||
@@ -129,10 +132,12 @@ export const EventSetupTab = (
|
||||
};
|
||||
});
|
||||
|
||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
}));
|
||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180].map(
|
||||
(mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
})
|
||||
);
|
||||
|
||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||
MultiValue<{
|
||||
@@ -301,7 +306,7 @@ export const EventSetupTab = (
|
||||
"h-4 w-4",
|
||||
// invert all the icons except app icons
|
||||
eventLocationType.iconUrl &&
|
||||
!eventLocationType.iconUrl.startsWith("/app-store") &&
|
||||
eventLocationType.iconUrl.includes("-dark") &&
|
||||
"dark:invert"
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
@@ -383,6 +388,9 @@ export const EventSetupTab = (
|
||||
|
||||
const lengthLockedProps = shouldLockDisableProps("length");
|
||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||
const urlPrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -411,10 +419,10 @@ export const EventSetupTab = (
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{CAL_URL?.replace(/^(https?:|)\/\//, "")}/
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
? "team/" + team.slug
|
||||
? (orgBranding ? "" : "team/") + team.slug
|
||||
: eventType.users[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
@@ -501,6 +509,8 @@ export const EventSetupTab = (
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_multiple_durations") : undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -7,7 +9,6 @@ import type { Options } from "react-select";
|
||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
@@ -16,13 +17,14 @@ interface IUserToValue {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const mapUserToValue = ({ id, name, username, email }: IUserToValue, pendingString: string) => ({
|
||||
const mapUserToValue = ({ id, name, username, avatar, email }: IUserToValue, pendingString: string) => ({
|
||||
value: `${id || ""}`,
|
||||
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
|
||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||
avatar,
|
||||
email,
|
||||
});
|
||||
|
||||
@@ -99,6 +101,7 @@ const CheckedHostField = ({
|
||||
isFixed,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
...rest
|
||||
}: {
|
||||
labelText: string;
|
||||
@@ -107,6 +110,7 @@ const CheckedHostField = ({
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
helperText?: React.ReactNode | string;
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-5 p-4">
|
||||
@@ -136,11 +140,24 @@ const CheckedHostField = ({
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FixedHostHelper = (
|
||||
<Trans i18nKey="fixed_host_helper">
|
||||
Add anyone who needs to attend the event.
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#fixed-hosts">
|
||||
Learn more
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
@@ -167,6 +184,7 @@ const RoundRobinHosts = ({
|
||||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
helperText={FixedHostHelper}
|
||||
/>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
@@ -175,6 +193,7 @@ const RoundRobinHosts = ({
|
||||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("round_robin_hosts")}
|
||||
helperText={t("round_robin_helper")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ type Props = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
isUpdateMutationLoading?: boolean;
|
||||
availability?: AvailabilityOption;
|
||||
isUserOrganizationAdmin: boolean;
|
||||
};
|
||||
|
||||
function getNavigation(props: {
|
||||
@@ -133,6 +134,7 @@ function EventTypeSingleLayout({
|
||||
isUpdateMutationLoading,
|
||||
formMethods,
|
||||
availability,
|
||||
isUserOrganizationAdmin,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
@@ -142,7 +144,8 @@ function EventTypeSingleLayout({
|
||||
const hasPermsToDelete =
|
||||
currentUserMembership?.role !== "MEMBER" ||
|
||||
!currentUserMembership ||
|
||||
eventType.schedulingType === SchedulingType.MANAGED;
|
||||
eventType.schedulingType === SchedulingType.MANAGED ||
|
||||
isUserOrganizationAdmin;
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -20,10 +20,11 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const { t } = useLocale();
|
||||
const { nextStep } = props;
|
||||
|
||||
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
|
||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId! },
|
||||
{ scheduleId: defaultScheduleId ?? undefined },
|
||||
{
|
||||
enabled: !!defaultScheduleId,
|
||||
enabled: !!scheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -98,14 +98,7 @@ const UserProfile = () => {
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && (
|
||||
<Avatar
|
||||
alt={user.username || "user avatar"}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
)}
|
||||
{user && <Avatar alt={user.username || "user avatar"} size="lg" imageSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
|
||||
@@ -12,9 +12,6 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.public.i18n.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
|
||||
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
|
||||
}
|
||||
|
||||
interface DisableTwoFactorValues {
|
||||
backupCode: string;
|
||||
totpCode: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -33,11 +35,19 @@ const DisableTwoFactorAuthModal = ({
|
||||
}: DisableTwoFactorAuthModalProps) => {
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<DisableTwoFactorValues>();
|
||||
|
||||
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
||||
const resetForm = (clearPassword = true) => {
|
||||
if (clearPassword) form.setValue("password", "");
|
||||
form.setValue("backupCode", "");
|
||||
form.setValue("totpCode", "");
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
@@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
||||
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
|
||||
if (response.status === 200) {
|
||||
setTwoFactorLostAccess(false);
|
||||
resetForm();
|
||||
onDisable();
|
||||
return;
|
||||
}
|
||||
@@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
}
|
||||
if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
} else if (body.error === ErrorCode.SecondFactorRequired) {
|
||||
setErrorMessage(t("2fa_required"));
|
||||
}
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage(t("incorrect_2fa"));
|
||||
} else if (body.error === ErrorCode.IncorrectBackupCode) {
|
||||
setErrorMessage(t("incorrect_backup_code"));
|
||||
} else if (body.error === ErrorCode.MissingBackupCodes) {
|
||||
setErrorMessage(t("missing_backup_codes"));
|
||||
} else {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
@@ -78,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
<div className="mb-8">
|
||||
{!disablePassword && (
|
||||
<PasswordField
|
||||
required
|
||||
labelProps={{
|
||||
className: "block text-sm font-medium text-default",
|
||||
}}
|
||||
@@ -85,12 +100,25 @@ const DisableTwoFactorAuthModal = ({
|
||||
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
|
||||
/>
|
||||
)}
|
||||
<TwoFactor center={false} />
|
||||
{twoFactorLostAccess ? (
|
||||
<BackupCode center={false} />
|
||||
) : (
|
||||
<TwoFactor center={false} autoFocus={false} />
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="relative mt-5">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
setTwoFactorLostAccess(!twoFactorLostAccess);
|
||||
resetForm(false);
|
||||
}}>
|
||||
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
|
||||
|
||||
enum SetupStep {
|
||||
ConfirmPassword,
|
||||
DisplayBackupCodes,
|
||||
DisplayQrCode,
|
||||
EnterTotpCode,
|
||||
}
|
||||
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [backupCodesUrl, setBackupCodesUrl] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const resetState = () => {
|
||||
setPassword("");
|
||||
setErrorMessage(null);
|
||||
setStep(SetupStep.ConfirmPassword);
|
||||
};
|
||||
|
||||
async function handleSetup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
setBackupCodes(body.backupCodes);
|
||||
|
||||
// create backup codes download url
|
||||
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
|
||||
setBackupCodesUrl(URL.createObjectURL(textBlob));
|
||||
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status === 200) {
|
||||
onEnable();
|
||||
setStep(SetupStep.DisplayBackupCodes);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
}
|
||||
}, [form, handleEnableRef, totpCode]);
|
||||
|
||||
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
<DialogContent
|
||||
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<TextField
|
||||
<PasswordField
|
||||
label={t("password")}
|
||||
type="password"
|
||||
name="password"
|
||||
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
|
||||
{backupCodes.map((code) => (
|
||||
<div key={code}>{formatBackupCode(code)}</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<div className="-mt-4 pb-2">
|
||||
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</div>
|
||||
</WithStep>
|
||||
<DialogFooter className="mt-8" showDivider>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{step !== SetupStep.DisplayBackupCodes ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
resetState();
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetState();
|
||||
onEnable();
|
||||
}}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="backup-codes-copy"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
|
||||
showToast(t("backup_codes_copied"), "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
|
||||
<Button color="primary" data-testid="backup-codes-download">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
</WithStep>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
|
||||
});
|
||||
},
|
||||
|
||||
async disable(password: string, code: string) {
|
||||
async disable(password: string, code: string, backupCode: string) {
|
||||
return fetch("/api/auth/two-factor/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password, code }),
|
||||
body: JSON.stringify({ password, code, backupCode }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
}),
|
||||
});
|
||||
|
||||
const formMethods = useForm<{
|
||||
username: string;
|
||||
email_address: string;
|
||||
full_name: string;
|
||||
password: string;
|
||||
}>({
|
||||
type formSchemaType = z.infer<typeof formSchema>;
|
||||
|
||||
const formMethods = useForm<formSchemaType>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
@@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
props.onError();
|
||||
};
|
||||
|
||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = formMethods.handleSubmit(async (data) => {
|
||||
props.onSubmit();
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
@@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("username", e.target.value);
|
||||
await formMethods.trigger("username");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<TextField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("full_name", e.target.value);
|
||||
await formMethods.trigger("full_name");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
color={formMethods.formState.errors.full_name ? "warn" : ""}
|
||||
type="text"
|
||||
name="full_name"
|
||||
@@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<EmailField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("email_address", e.target.value);
|
||||
await formMethods.trigger("email_address");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="my-0"
|
||||
name="email_address"
|
||||
/>
|
||||
@@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
|
||||
<PasswordField
|
||||
value={value || ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("password", e.target.value);
|
||||
await formMethods.trigger("password");
|
||||
}}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
hintErrors={["caplow", "admin_min", "num"]}
|
||||
name="password"
|
||||
className="my-0"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -50,7 +51,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const { update } = useSession();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
@@ -94,7 +95,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
debouncedApiCall(inputUsernameValue);
|
||||
}, [debouncedApiCall, inputUsernameValue]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
@@ -104,9 +104,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.public.i18n.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { RefCallback } from "react";
|
||||
@@ -24,7 +25,7 @@ interface ICustomUsernameProps {
|
||||
|
||||
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const { update } = useSession();
|
||||
|
||||
const {
|
||||
currentUsername,
|
||||
@@ -65,8 +66,6 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
}
|
||||
}, [inputUsernameValue, debouncedApiCall, currentUsername]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
@@ -77,9 +76,6 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.public.i18n.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = () => {
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import { PremiumTextfield } from "./PremiumTextfield";
|
||||
import { UsernameTextfield } from "./UsernameTextfield";
|
||||
|
||||
export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield;
|
||||
|
||||
interface UsernameAvailabilityFieldProps {
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
}
|
||||
|
||||
function useUserNamePrefix(organization: RouterOutputs["viewer"]["me"]["organization"]): string {
|
||||
return organization
|
||||
? organization.slug
|
||||
? getOrgFullDomain(organization.slug, { protocol: false })
|
||||
: organization.metadata && organization.metadata.requestedSlug
|
||||
? getOrgFullDomain(organization.metadata.requestedSlug, { protocol: false })
|
||||
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")
|
||||
: process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "");
|
||||
}
|
||||
export const getUsernameAvailabilityComponent = (isPremium: boolean) => {
|
||||
if (isPremium)
|
||||
return dynamic(() => import("./PremiumTextfield").then((m) => m.PremiumTextfield), { ssr: false });
|
||||
return dynamic(() => import("./UsernameTextfield").then((m) => m.UsernameTextfield), { ssr: false });
|
||||
};
|
||||
|
||||
export const UsernameAvailabilityField = ({
|
||||
onSuccessMutation,
|
||||
@@ -49,7 +40,12 @@ export const UsernameAvailabilityField = ({
|
||||
},
|
||||
});
|
||||
|
||||
const usernamePrefix = useUserNamePrefix(user.organization);
|
||||
const UsernameAvailability = getUsernameAvailabilityComponent(!IS_SELF_HOSTED && !user.organization?.id);
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
const usernamePrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
@@ -65,7 +61,8 @@ export const UsernameAvailabilityField = ({
|
||||
setInputUsernameValue={onChange}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
addOnLeading={usernamePrefix}
|
||||
disabled={!!user.organization?.id}
|
||||
addOnLeading={`${usernamePrefix}/`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { components } from "react-select";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
@@ -28,7 +27,7 @@ const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
src={icon}
|
||||
alt="cover"
|
||||
// invert all the icons except app icons
|
||||
className={cx("h-3.5 w-3.5", icon && !icon.startsWith("/app-store") && "dark:invert")}
|
||||
className={classNames(icon.includes("-dark") && "dark:invert", "h-3.5 w-3.5")}
|
||||
/>
|
||||
)}
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
@@ -57,7 +56,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
{e.icon && (
|
||||
<img
|
||||
src={e.icon}
|
||||
alt="app-icon"
|
||||
className={classNames(e.icon.includes("-dark") && "dark:invert", "h-5 w-5")}
|
||||
/>
|
||||
)}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user