Compare commits
270
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea6e105e6 | ||
|
|
a082cf73a0 | ||
|
|
61268f36b9 | ||
|
|
99cd0c5c41 | ||
|
|
2266301469 | ||
|
|
30b0012e6a | ||
|
|
77d1958c42 | ||
|
|
88f347f18c | ||
|
|
09cf8885cf | ||
|
|
f3d4eab951 | ||
|
|
866b6cc233 | ||
|
|
cafecad8ae | ||
|
|
0ea673e507 | ||
|
|
cf367c9340 | ||
|
|
d72e15649f | ||
|
|
89a3b77a1d | ||
|
|
38f6f71ae0 | ||
|
|
40caa82e55 | ||
|
|
b9aee3b18c | ||
|
|
3f8d2f2b38 | ||
|
|
c3731d9f17 | ||
|
|
6e4b2d8886 | ||
|
|
cf13c06c57 | ||
|
|
e309e72be3 | ||
|
|
b4d804de7c | ||
|
|
f501ef3269 | ||
|
|
79f1074f93 | ||
|
|
48aa6bf489 | ||
|
|
8ed2aacd9e | ||
|
|
dfc5d914fc | ||
|
|
0a11c45065 | ||
|
|
4ae5de5a3a | ||
|
|
e6959f3c7c | ||
|
|
67af8b4973 | ||
|
|
7794f2596e | ||
|
|
167cf6e352 | ||
|
|
3d778ba481 | ||
|
|
b8f0000b59 | ||
|
|
618ef63017 | ||
|
|
ae8e633716 | ||
|
|
4a23832388 | ||
|
|
02e486d757 | ||
|
|
af6e24f433 | ||
|
|
f9e24f51a7 | ||
|
|
9e9f815809 | ||
|
|
a91561f3c2 | ||
|
|
ff79064ca7 | ||
|
|
1a573e1619 | ||
|
|
511be7386c | ||
|
|
2a0991c259 | ||
|
|
2e454c9723 | ||
|
|
7e6d94c8e0 | ||
|
|
e18b790ccd | ||
|
|
0274417ad1 | ||
|
|
a8a3a93ace | ||
|
|
88bbab216e | ||
|
|
f74434a282 | ||
|
|
ed6f9d8252 | ||
|
|
a19ccab16a | ||
|
|
77369a13f1 | ||
|
|
8231356d2a | ||
|
|
469a942b5a | ||
|
|
0afe6cf5d1 | ||
|
|
75a161fe35 | ||
|
|
eb1ed107ba | ||
|
|
13e2e340ac | ||
|
|
0af62da16b | ||
|
|
9b323cbcc8 | ||
|
|
18a0e2d75c | ||
|
|
87c89a9968 | ||
|
|
e237749abd | ||
|
|
994a36d039 | ||
|
|
c989817373 | ||
|
|
e3fb7340aa | ||
|
|
e4ef2832cd | ||
|
|
881602cf1c | ||
|
|
4c01f171ae | ||
|
|
d9817fcb07 | ||
|
|
a784af3e47 | ||
|
|
c268925330 | ||
|
|
378e8e0330 | ||
|
|
8b3fbb672b | ||
|
|
2d5a86774e | ||
|
|
0865104a77 | ||
|
|
1914b37ee4 | ||
|
|
58ef0abb99 | ||
|
|
13ba680a73 | ||
|
|
f40c6da93b | ||
|
|
fc2e81e89a | ||
|
|
293dd1a140 | ||
|
|
b7fda31678 | ||
|
|
94a04f0dc2 | ||
|
|
2338f85dcf | ||
|
|
fbaccc8e5f | ||
|
|
750db38d38 | ||
|
|
8829e1ca3f | ||
|
|
ab703a4346 | ||
|
|
8d5a7b4e10 | ||
|
|
6ba3049314 | ||
|
|
abe92cd693 | ||
|
|
6c6cf0433a | ||
|
|
211b958c58 | ||
|
|
01a0d43943 | ||
|
|
3133eb5aeb | ||
|
|
76b68bebd7 | ||
|
|
c20e723e34 | ||
|
|
7b7478c0a9 | ||
|
+3 |
ebeb008f9b | ||
|
|
a90d765fca | ||
|
|
af51df2b4d | ||
|
|
076e93ca56 | ||
|
|
47f45409d2 | ||
|
|
759d5b8663 | ||
|
|
c554396572 | ||
|
|
fafd3fb181 | ||
|
|
eb05d547de | ||
|
|
1fa8a5ef25 | ||
|
|
5a350efb40 | ||
|
|
242f86891a | ||
|
|
ce6969d058 | ||
|
|
26f8246b91 | ||
|
|
4baa9b6bc1 | ||
|
|
02c86d0268 | ||
|
|
49f99d69a8 | ||
|
|
659d9cf767 | ||
|
|
4d662d31a1 | ||
|
|
6fe6540bc6 | ||
|
|
ee1904bb2c | ||
|
|
aaa59f1455 | ||
|
|
e29b7e83b8 | ||
|
|
6af473a486 | ||
|
|
bf8f966811 | ||
|
|
c64b663514 | ||
|
|
29314cde75 | ||
|
|
3ea73e75d6 | ||
|
|
d18e23378b | ||
|
|
736f1f594c | ||
|
|
09b1643478 | ||
|
|
86f6e8bac3 | ||
|
|
9c65d02e84 | ||
|
|
be9ab02a7e | ||
|
|
cfefba6d4d | ||
|
|
815e13303b | ||
|
|
7c8dbb3ea3 | ||
|
|
cabd459682 | ||
|
|
33aedbae20 | ||
|
|
d3fbc73a39 | ||
|
|
cfda4757e4 | ||
|
|
895ed48bd6 | ||
|
|
eda328d196 | ||
|
|
1c501d1501 | ||
|
|
08a57f32c5 | ||
|
|
b5a26bc227 | ||
|
|
737b019199 | ||
|
|
b41031626f | ||
|
|
a1fc4bccb9 | ||
|
|
d764b6a32d | ||
|
|
87cb8b170a | ||
|
|
badd0de33c | ||
|
|
124fd8ebca | ||
|
|
5d0dc3f546 | ||
|
|
e4baf15b53 | ||
|
|
a1bd8b6259 | ||
|
|
7987c9f45d | ||
|
|
781d0d92df | ||
|
|
b71d8baccc | ||
|
|
a71d949a0c | ||
|
|
e55cee443b | ||
|
|
c3634b3aba | ||
|
|
cd576f5378 | ||
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe | ||
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 | ||
|
|
6e3de84067 | ||
|
|
c6b19200a9 | ||
|
|
91932212e3 | ||
|
|
f81bb92beb | ||
|
|
1d1a242a72 | ||
|
|
ad4af64036 | ||
|
+5 |
2cc7827b0e | ||
|
|
ef6618743d | ||
|
|
3601a9adf1 | ||
|
|
0cc2367556 | ||
|
|
f7b201b0b5 | ||
|
|
82063cc9a1 | ||
|
|
4b2f89d6e8 | ||
|
|
5360cb511d | ||
|
|
704bbf2757 | ||
|
|
7fef683abf | ||
|
|
63df3d9c14 | ||
|
|
32d2fd51e5 | ||
|
|
016a966057 | ||
|
|
dc967cffbe | ||
|
|
4489cb8fde | ||
|
|
babd514c64 | ||
|
|
2e5cc11f29 | ||
|
|
84bc330fa8 | ||
|
|
4cb7e4a91a | ||
|
|
d1bd05a791 | ||
|
|
96a6350143 | ||
|
|
c8bea5b143 | ||
|
|
1cdd8ebc53 | ||
|
|
d78ed21a9f | ||
|
|
4e92b6edeb | ||
|
|
7b2f811bfe | ||
|
|
7b6d5d6de3 | ||
|
|
12b418b532 | ||
|
|
3e299f9511 | ||
|
|
1b9691ddaf | ||
|
|
a11ea3dd1e | ||
|
|
b21400c613 | ||
|
|
6c936472d2 | ||
|
|
3b823670ea | ||
|
|
6b10127d97 | ||
|
|
9cd806ff61 | ||
|
|
628b169f53 | ||
|
|
19d7764e9d | ||
|
|
30061c0d58 | ||
|
|
ee073cdf35 | ||
|
|
4ff5f5f3fa | ||
|
|
f905ae7502 | ||
|
|
a1c0daa1b5 | ||
|
|
b155d736d8 | ||
|
|
e42013bee1 | ||
|
|
fde1d671fb | ||
|
|
9ccf5f3723 | ||
|
|
91ee225f14 | ||
|
|
e6a0a0fc69 | ||
|
|
e98024b08e | ||
|
|
8d54d80dee | ||
|
|
751aa62935 | ||
|
|
f149650911 | ||
|
|
f901fba0fc | ||
|
|
cd50719b03 | ||
|
|
000324c02d | ||
|
|
cc6beedfb0 | ||
|
|
1d28215c5e | ||
|
|
045ea34580 | ||
|
|
3228d8d869 | ||
|
|
6b9115dfde | ||
|
|
6259b0b3cb | ||
|
|
f991669b8d | ||
|
|
b93568359a | ||
|
|
66b52bb19f | ||
|
|
b9c49b4567 | ||
|
|
29e1dcb43c | ||
|
|
3ab676794f | ||
|
|
877fdbf46a | ||
|
|
b05e1b4589 |
@@ -0,0 +1,187 @@
|
|||||||
|
language: en-US
|
||||||
|
tone_instructions: ''
|
||||||
|
early_access: false
|
||||||
|
enable_free_tier: true
|
||||||
|
reviews:
|
||||||
|
profile: chill
|
||||||
|
request_changes_workflow: false
|
||||||
|
high_level_summary: false
|
||||||
|
high_level_summary_placeholder: '@coderabbitai summary'
|
||||||
|
high_level_summary_in_walkthrough: false
|
||||||
|
auto_title_placeholder: '@coderabbitai'
|
||||||
|
auto_title_instructions: ''
|
||||||
|
review_status: true
|
||||||
|
commit_status: true
|
||||||
|
fail_commit_status: false
|
||||||
|
collapse_walkthrough: false
|
||||||
|
changed_files_summary: false
|
||||||
|
sequence_diagrams: false
|
||||||
|
estimate_code_review_effort: false
|
||||||
|
assess_linked_issues: true
|
||||||
|
related_issues: false
|
||||||
|
related_prs: true
|
||||||
|
suggested_labels: false
|
||||||
|
auto_apply_labels: false
|
||||||
|
suggested_reviewers: false
|
||||||
|
auto_assign_reviewers: false
|
||||||
|
poem: false
|
||||||
|
labeling_instructions: []
|
||||||
|
path_filters:
|
||||||
|
- '!**/node_modules/**'
|
||||||
|
- '!**/dist/**'
|
||||||
|
- '!**/build/**'
|
||||||
|
- '!**/.next/**'
|
||||||
|
path_instructions:
|
||||||
|
- path: "**/*.{ts,tsx,js,jsx}"
|
||||||
|
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
|
||||||
|
abort_on_close: true
|
||||||
|
disable_cache: false
|
||||||
|
auto_review:
|
||||||
|
enabled: true
|
||||||
|
auto_incremental_review: true
|
||||||
|
ignore_title_keywords: []
|
||||||
|
labels: []
|
||||||
|
drafts: false
|
||||||
|
base_branches: []
|
||||||
|
finishing_touches:
|
||||||
|
docstrings:
|
||||||
|
enabled: true
|
||||||
|
unit_tests:
|
||||||
|
enabled: true
|
||||||
|
pre_merge_checks:
|
||||||
|
docstrings:
|
||||||
|
mode: warning
|
||||||
|
threshold: 80
|
||||||
|
title:
|
||||||
|
mode: warning
|
||||||
|
requirements: ''
|
||||||
|
description:
|
||||||
|
mode: warning
|
||||||
|
issue_assessment:
|
||||||
|
mode: warning
|
||||||
|
tools:
|
||||||
|
ast-grep:
|
||||||
|
rule_dirs: []
|
||||||
|
util_dirs: []
|
||||||
|
essential_rules: true
|
||||||
|
packages: []
|
||||||
|
shellcheck:
|
||||||
|
enabled: true
|
||||||
|
ruff:
|
||||||
|
enabled: true
|
||||||
|
markdownlint:
|
||||||
|
enabled: true
|
||||||
|
github-checks:
|
||||||
|
enabled: true
|
||||||
|
timeout_ms: 180000
|
||||||
|
languagetool:
|
||||||
|
enabled: true
|
||||||
|
enabled_rules: []
|
||||||
|
disabled_rules: []
|
||||||
|
enabled_categories: []
|
||||||
|
disabled_categories: []
|
||||||
|
enabled_only: false
|
||||||
|
level: default
|
||||||
|
biome:
|
||||||
|
enabled: true
|
||||||
|
hadolint:
|
||||||
|
enabled: true
|
||||||
|
swiftlint:
|
||||||
|
enabled: true
|
||||||
|
phpstan:
|
||||||
|
enabled: true
|
||||||
|
level: default
|
||||||
|
phpmd:
|
||||||
|
enabled: true
|
||||||
|
phpcs:
|
||||||
|
enabled: true
|
||||||
|
golangci-lint:
|
||||||
|
enabled: true
|
||||||
|
yamllint:
|
||||||
|
enabled: true
|
||||||
|
gitleaks:
|
||||||
|
enabled: true
|
||||||
|
checkov:
|
||||||
|
enabled: true
|
||||||
|
detekt:
|
||||||
|
enabled: true
|
||||||
|
eslint:
|
||||||
|
enabled: true
|
||||||
|
flake8:
|
||||||
|
enabled: true
|
||||||
|
rubocop:
|
||||||
|
enabled: true
|
||||||
|
buf:
|
||||||
|
enabled: true
|
||||||
|
regal:
|
||||||
|
enabled: true
|
||||||
|
actionlint:
|
||||||
|
enabled: true
|
||||||
|
pmd:
|
||||||
|
enabled: true
|
||||||
|
cppcheck:
|
||||||
|
enabled: true
|
||||||
|
semgrep:
|
||||||
|
enabled: true
|
||||||
|
circleci:
|
||||||
|
enabled: true
|
||||||
|
clippy:
|
||||||
|
enabled: true
|
||||||
|
sqlfluff:
|
||||||
|
enabled: true
|
||||||
|
prismaLint:
|
||||||
|
enabled: true
|
||||||
|
pylint:
|
||||||
|
enabled: true
|
||||||
|
oxc:
|
||||||
|
enabled: true
|
||||||
|
shopifyThemeCheck:
|
||||||
|
enabled: true
|
||||||
|
luacheck:
|
||||||
|
enabled: true
|
||||||
|
brakeman:
|
||||||
|
enabled: true
|
||||||
|
dotenvLint:
|
||||||
|
enabled: true
|
||||||
|
htmlhint:
|
||||||
|
enabled: true
|
||||||
|
checkmake:
|
||||||
|
enabled: true
|
||||||
|
osvScanner:
|
||||||
|
enabled: true
|
||||||
|
chat:
|
||||||
|
art: true
|
||||||
|
auto_reply: true
|
||||||
|
integrations:
|
||||||
|
jira:
|
||||||
|
usage: auto
|
||||||
|
linear:
|
||||||
|
usage: auto
|
||||||
|
knowledge_base:
|
||||||
|
opt_out: false
|
||||||
|
web_search:
|
||||||
|
enabled: true
|
||||||
|
code_guidelines:
|
||||||
|
enabled: true
|
||||||
|
filePatterns: []
|
||||||
|
learnings:
|
||||||
|
scope: auto
|
||||||
|
issues:
|
||||||
|
scope: auto
|
||||||
|
jira:
|
||||||
|
usage: auto
|
||||||
|
project_keys: []
|
||||||
|
linear:
|
||||||
|
usage: auto
|
||||||
|
team_keys: []
|
||||||
|
pull_requests:
|
||||||
|
scope: auto
|
||||||
|
mcp:
|
||||||
|
usage: auto
|
||||||
|
disabled_servers: []
|
||||||
|
code_generation:
|
||||||
|
docstrings:
|
||||||
|
language: en-US
|
||||||
|
path_instructions: []
|
||||||
|
unit_tests:
|
||||||
|
path_instructions: []
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Review Instructions
|
# Review Instructions
|
||||||
|
|
||||||
|
## File Naming Conventions
|
||||||
|
|
||||||
|
- **Repository Files**: Must include `Repository` suffix, prefix with technology if applicable (e.g., `PrismaAppRepository.ts`), use PascalCase matching exported class
|
||||||
|
- **Service Files**: Must include `Service` suffix, use PascalCase matching exported class, avoid generic names (e.g., `MembershipService.ts`)
|
||||||
|
- **New files**: Avoid dot-suffixes like `.service.ts` or `.repository.ts`; reserve `.test.ts`, `.spec.ts`, `.types.ts` for their specific purposes
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
|
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
|
||||||
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
|
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
|
||||||
- For Prisma queries:
|
- For Prisma queries:
|
||||||
|
|||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
module.exports = require("./packages/config/eslint-preset");
|
// This configuration only applies to the package manager root.
|
||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
|
module.exports = {
|
||||||
|
extends: ["./packages/config/eslint-preset.js"],
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Atoms E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'packages/platform/atoms/**'
|
||||||
|
- 'packages/platform/examples/base/**'
|
||||||
|
- 'apps/api/v2/**'
|
||||||
|
- '.github/workflows/atoms-e2e.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8096
|
||||||
|
ATOMS_E2E_OAUTH_CLIENT_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID }}
|
||||||
|
ATOMS_E2E_OAUTH_CLIENT_SECRET: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET }}
|
||||||
|
ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
|
||||||
|
ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
|
||||||
|
ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
|
||||||
|
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
atoms-e2e:
|
||||||
|
timeout-minutes: 15
|
||||||
|
name: Atoms E2E Tests
|
||||||
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
- uses: ./.github/actions/yarn-install
|
||||||
|
- uses: ./.github/actions/yarn-playwright-install
|
||||||
|
- name: Run Atoms E2E Tests
|
||||||
|
working-directory: packages/platform/examples/base
|
||||||
|
run: yarn test:e2e
|
||||||
|
- name: Upload Test Results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: atoms-e2e-test-results
|
||||||
|
path: test-results
|
||||||
|
retention-days: 7
|
||||||
|
- name: Upload Playwright Report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: atoms-e2e-playwright-report
|
||||||
|
path: playwright-report
|
||||||
|
retention-days: 7
|
||||||
@@ -34,4 +34,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
||||||
|
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||||
@@ -22,6 +22,7 @@ env:
|
|||||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -95,6 +95,57 @@ Write with the future in mind. If there are trade-offs, edge cases, or temporary
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
## File Naming Conventions
|
||||||
|
|
||||||
|
To ensure consistency and make files easy to fuzzy-find, we follow the naming conventions below for **services**, **repositories**, and other class-based files.
|
||||||
|
|
||||||
|
### Repository Files
|
||||||
|
|
||||||
|
- Repository class files must include the `Repository` suffix.
|
||||||
|
- If the repository is backed by a specific technology (e.g. Prisma), prefix the filename and class name with it.
|
||||||
|
- File name must match the exported class exactly (PascalCase).
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
|
||||||
|
`Prisma<Entity>Repository.ts`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// File: PrismaAppRepository.ts
|
||||||
|
export class PrismaAppRepository { ... }
|
||||||
|
|
||||||
|
// File: PrismaMembershipRepository.ts
|
||||||
|
export class PrismaMembershipRepository { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids ambiguous filenames like app.ts and improves discoverability in editors.
|
||||||
|
|
||||||
|
### Service Files
|
||||||
|
|
||||||
|
- Service class files must include the Service suffix.
|
||||||
|
- File name should be in PascalCase, matching the exported class.
|
||||||
|
- Keep naming specific — avoid generic names like AppService.ts.
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
|
||||||
|
`<Entity>Service.ts`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// File: MembershipService.ts
|
||||||
|
export class MembershipService { ... }
|
||||||
|
|
||||||
|
// File: HashedLinkService.ts
|
||||||
|
export class HashedLinkService { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- New files must avoid dot-suffixes like .service.ts or .repository.ts; these will be migrated from the existing codebase progressively.
|
||||||
|
- We still reserve suffixes such as .test.ts, .spec.ts, and .types.ts for their respective use cases.
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
[See README](https://github.com/calcom/cal.com#development)
|
[See README](https://github.com/calcom/cal.com#development)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# Checkly Tests
|
# Checkly Tests
|
||||||
|
|
||||||
Run as `yarn checkly test`
|
Run as `yarn checkly test`
|
||||||
|
Optionally, run as `yarn checkly test --record` to be able to get detailed debugging view in Checkly UI
|
||||||
|
Run the tests for a particylar file as `yarn checkly test {filePattern}`
|
||||||
Deploy the tests as `yarn checkly deploy`
|
Deploy the tests as `yarn checkly deploy`
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("CSP Headers", () => {
|
||||||
|
test("Login page should have CSP header with nonce", async ({ page }) => {
|
||||||
|
const targetUrl = process.env.ENVIRONMENT_URL || "https://app.cal.com";
|
||||||
|
|
||||||
|
const response = await page.goto(`${targetUrl}/auth/login`);
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const cspHeader = response?.headers()["content-security-policy"];
|
||||||
|
expect(cspHeader).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify nonce is present in CSP header
|
||||||
|
const nonceMatch = cspHeader?.match(/'nonce-([^']+)'/);
|
||||||
|
expect(nonceMatch).toBeTruthy();
|
||||||
|
expect(nonceMatch![1]).toHaveLength(24);
|
||||||
|
expect(nonceMatch![1]).toMatch(/==$/);
|
||||||
|
|
||||||
|
await page.screenshot({ path: "screenshot.jpg" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -199,7 +199,7 @@ There's some settings that you'll need to setup.
|
|||||||
Under Vercel > Your API Project > Settings
|
Under Vercel > Your API Project > Settings
|
||||||
|
|
||||||
In General > Build & Development Settings
|
In General > Build & Development Settings
|
||||||
BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps`
|
BUILD COMMAND: `yarn turbo run build --filter=@calcom/api --no-deps`
|
||||||
OUTPUT DIRECTORY: `apps/api/.next`
|
OUTPUT DIRECTORY: `apps/api/.next`
|
||||||
|
|
||||||
In Git > Ignored Build Step
|
In Git > Ignored Build Step
|
||||||
|
|||||||
@@ -14,11 +14,21 @@ import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
|||||||
|
|
||||||
import { verifyApiKey } from "./verifyApiKey";
|
import { verifyApiKey } from "./verifyApiKey";
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/crypto", () => ({
|
||||||
|
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
||||||
|
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
||||||
|
}));
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX");
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockDeploymentRepository: IDeploymentRepository = {
|
const mockDeploymentRepository: IDeploymentRepository = {
|
||||||
@@ -26,8 +36,7 @@ const mockDeploymentRepository: IDeploymentRepository = {
|
|||||||
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Fix the skip condition for this test suite
|
describe("Verify API key", () => {
|
||||||
describe.skip("Verify API key", () => {
|
|
||||||
let service: ILicenseKeyService;
|
let service: ILicenseKeyService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -33,12 +33,12 @@
|
|||||||
"@sentry/nextjs": "^9.15.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^15.3.0",
|
"next": "^15.4.5",
|
||||||
"next-api-middleware": "^1.0.1",
|
"next-api-middleware": "^1.0.1",
|
||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.0-beta",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
|
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -191,8 +191,9 @@ const availabilitySchema = z
|
|||||||
async function handler(req: NextApiRequest) {
|
async function handler(req: NextApiRequest) {
|
||||||
const { isSystemWideAdmin, userId: reqUserId } = req;
|
const { isSystemWideAdmin, userId: reqUserId } = req;
|
||||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||||
|
const userAvailabilityService = getUserAvailabilityService();
|
||||||
if (!teamId)
|
if (!teamId)
|
||||||
return getUserAvailability({
|
return userAvailabilityService.getUserAvailability({
|
||||||
username,
|
username,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
const availabilities = members.map(async (user) => {
|
const availabilities = members.map(async (user) => {
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
availability: await getUserAvailability({
|
availability: await userAvailabilityService.getUserAvailability({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
|
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -106,6 +108,11 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found");
|
||||||
|
}
|
||||||
|
|
||||||
return { booking: schemaBookingReadPublic.parse(booking) };
|
return { booking: schemaBookingReadPublic.parse(booking) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,14 @@ async function handler(req: NextApiRequest) {
|
|||||||
throw new HttpError({ statusCode: 400, message: knownError.message });
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (knownError?.message === ErrorCode.RequestBodyInvalid) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownError?.message === ErrorCode.EventTypeNotFound) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildBooking, buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/bookings/[id]/_delete";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleCancelBooking", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
handleCancelBooking: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
const bookingId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/bookings/[id]", () => {
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should cancel booking successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "User requested cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to cancel any booking", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Admin cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should return 404 when booking not found", async () => {
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: "999",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Test cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid booking ID", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: "invalid",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Test cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 403 when user doesn't have permission to cancel booking", async () => {
|
||||||
|
const otherUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Unauthorized cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 when required cancellation reason is missing", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildBooking, buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/bookings/[id]/_patch";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
const bookingId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/bookings/[id]", () => {
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should update booking successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
title: "Original Title",
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
title: "Updated Title",
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.booking.title).toBe("Updated Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to update any booking", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
title: "Original Title",
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
title: "Admin Updated Title",
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Admin Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update booking times successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
startTime: new Date("2024-01-01T10:00:00Z"),
|
||||||
|
endTime: new Date("2024-01-01T11:00:00Z"),
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newStartTime = new Date("2024-01-01T14:00:00Z");
|
||||||
|
const newEndTime = new Date("2024-01-01T15:00:00Z");
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
startTime: newStartTime,
|
||||||
|
endTime: newEndTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
startTime: newStartTime.toISOString(),
|
||||||
|
endTime: newEndTime.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(new Date(responseData.booking.startTime)).toEqual(newStartTime);
|
||||||
|
expect(new Date(responseData.booking.endTime)).toEqual(newEndTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should return 404 when booking not found", async () => {
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: "999",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid booking ID", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: "invalid",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 403 when user doesn't have permission to update booking", async () => {
|
||||||
|
const otherUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Unauthorized Update",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 401 when non-admin tries to change userId", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
userId: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid date format", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
startTime: "invalid-date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAccessibleUsers,
|
||||||
|
retrieveOrgScopedAccessibleUsers,
|
||||||
|
} from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
|
import { handler } from "../../../pages/api/bookings/_get";
|
||||||
|
|
||||||
|
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => ({
|
||||||
|
getAccessibleUsers: vi.fn(),
|
||||||
|
retrieveOrgScopedAccessibleUsers: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
(getAccessibleUsers as any).mockResolvedValue([userId]);
|
||||||
|
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([userId]);
|
||||||
|
|
||||||
|
prismaMock.membership.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
id: 1,
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.user.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/bookings", () => {
|
||||||
|
describe("Query parameter validation", () => {
|
||||||
|
test("should validate status parameter correctly", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept valid status parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "upcoming",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate dateFrom parameter format", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "invalid-date",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate dateTo parameter format", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "invalid-date",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate sortBy parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "invalid_field",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate order parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
order: "invalid_order",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Permission logic", () => {
|
||||||
|
test("should only return user's own bookings for regular user", async () => {
|
||||||
|
const mockBookings = [buildBooking({ id: 1, userId: userId }), buildBooking({ id: 2, userId: userId })];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(2);
|
||||||
|
expect(result.bookings.every((b) => b.userId === userId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to access all bookings", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBookings = [
|
||||||
|
buildBooking({ id: 1, userId: 1 }),
|
||||||
|
buildBooking({ id: 2, userId: 2 }),
|
||||||
|
buildBooking({ id: 3, userId: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(3);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow org admin to access org bookings", async () => {
|
||||||
|
const orgAdminUserId = 999;
|
||||||
|
const mockBookings = [buildBooking({ id: 1, userId: 1 }), buildBooking({ id: 2, userId: 2 })];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([1, 2]);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = orgAdminUserId;
|
||||||
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Filtering edge cases", () => {
|
||||||
|
test("should handle empty attendeeEmail array", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: [],
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle single attendeeEmail string", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: "test@example.com",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle pagination edge cases", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 0,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle large skip values", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Expand parameter functionality", () => {
|
||||||
|
test("should handle invalid expand parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "invalid_field",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle valid expand parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "team",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,24 +4,184 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
||||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook, buildUser } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { Booking } from "@calcom/prisma/client";
|
import type { Booking } from "@calcom/prisma/client";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB", () => ({
|
||||||
|
getEventTypesFromDB: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEventTypeData = {
|
||||||
|
eventType: {
|
||||||
|
id: 1,
|
||||||
|
title: "Test Event",
|
||||||
|
profile: { organizationId: null },
|
||||||
|
users: [{ id: 1, email: "test@example.com", credentials: [] }],
|
||||||
|
hosts: [],
|
||||||
|
customInputs: [],
|
||||||
|
recurringEvent: null,
|
||||||
|
length: 15,
|
||||||
|
slug: "test-event",
|
||||||
|
price: 0,
|
||||||
|
currency: "USD",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
disableGuests: false,
|
||||||
|
minimumBookingNotice: 120,
|
||||||
|
beforeEventBuffer: 0,
|
||||||
|
afterEventBuffer: 0,
|
||||||
|
seatsPerTimeSlot: null,
|
||||||
|
seatsShowAttendees: false,
|
||||||
|
schedulingType: null,
|
||||||
|
periodType: "UNLIMITED",
|
||||||
|
periodStartDate: null,
|
||||||
|
periodEndDate: null,
|
||||||
|
periodDays: null,
|
||||||
|
periodCountCalendarDays: false,
|
||||||
|
locations: [],
|
||||||
|
metadata: {},
|
||||||
|
successRedirectUrl: null,
|
||||||
|
description: null,
|
||||||
|
team: null,
|
||||||
|
owner: { id: 1, email: "test@example.com", credentials: [] },
|
||||||
|
},
|
||||||
|
users: [{ id: 1, email: "test@example.com", credentials: [] }],
|
||||||
|
allCredentials: [],
|
||||||
|
destinationCalendar: null,
|
||||||
|
};
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
vi.mock("@calcom/features/webhooks/lib/sendPayload");
|
vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFindOriginalRescheduledBooking = vi.fn();
|
||||||
|
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
||||||
|
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.controller", () => ({
|
||||||
|
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
||||||
|
getQualifiedHostsService: vi.fn().mockReturnValue({
|
||||||
|
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||||
|
qualifiedRRHosts: [],
|
||||||
|
allFallbackRRHosts: [],
|
||||||
|
fixedHosts: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/EventManager", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
reschedule: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
create: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
createAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
updateAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
deleteEventsAndMeetings: vi.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
|
placeholderCreatedEvent: {
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/availability", () => ({
|
||||||
|
getUserAvailability: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
start: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
end: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getAvailableSlots: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
attendees: 1,
|
||||||
|
bookingUid: null,
|
||||||
|
users: [2],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", () => ({
|
||||||
|
ensureAvailableUsers: vi.fn().mockImplementation(async (eventType) => {
|
||||||
|
return eventType.users || [{ id: 2, email: "test@example.com", name: "Test User", isFixed: false }];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
||||||
|
ProfileRepository: {
|
||||||
|
findManyForUser: vi.fn().mockResolvedValue([]),
|
||||||
|
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
||||||
|
id: null,
|
||||||
|
upId: "usr-2",
|
||||||
|
username: "test-user",
|
||||||
|
organizationId: null,
|
||||||
|
organization: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||||
|
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
|
||||||
|
checkIfTeamHasFeature: vi.fn().mockResolvedValue(false),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflows", () => ({
|
||||||
|
getAllWorkflows: vi.fn().mockResolvedValue([]),
|
||||||
|
workflowSelect: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
|
||||||
|
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/lib/server/i18n", () => {
|
||||||
|
const mockT = (key: string, options?: any) => {
|
||||||
|
if (key === "event_between_users") {
|
||||||
|
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
|
||||||
|
}
|
||||||
|
if (key === "scheduler") {
|
||||||
|
return "Scheduler";
|
||||||
|
}
|
||||||
|
if (key === "google_meet_warning") {
|
||||||
|
return "Google Meet warning";
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getTranslation: (key: string) => key,
|
getTranslation: vi.fn().mockResolvedValue(mockT),
|
||||||
|
t: mockT,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +212,14 @@ describe("POST /api/bookings - eventTypeId validation", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(
|
||||||
|
buildEventType({
|
||||||
|
id: 123,
|
||||||
|
profileId: null,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
const statusCode = res._getStatusCode();
|
const statusCode = res._getStatusCode();
|
||||||
@@ -63,9 +231,18 @@ describe("POST /api/bookings - eventTypeId validation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(true)("POST /api/bookings", () => {
|
describe("POST /api/bookings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFindOriginalRescheduledBooking.mockResolvedValue(null);
|
||||||
|
|
||||||
|
(getEventTypesFromDB as any).mockResolvedValue(mockEventTypeData.eventType);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Errors", () => {
|
describe("Errors", () => {
|
||||||
test("Missing required data", async () => {
|
test("Missing required data", async () => {
|
||||||
|
(getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.RequestBodyInvalid));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -73,102 +250,45 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message: ErrorCode.RequestBodyInvalid,
|
||||||
"invalid_type in 'eventTypeId': Required; invalid_type in 'title': Required; invalid_type in 'startTime': Required; invalid_type in 'startTime': Required; invalid_type in 'endTime': Required; invalid_type in 'endTime': Required",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Invalid eventTypeId", async () => {
|
test("Invalid eventTypeId", async () => {
|
||||||
|
(getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.EventTypeNotFound));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 999,
|
||||||
startTime: dayjs().toDate(),
|
startTime: dayjs().add(1, "day").toDate(),
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
endTime: dayjs().add(1, "day").add(15, "minutes").toDate(),
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message: ErrorCode.EventTypeNotFound,
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Missing recurringCount", async () => {
|
test.skip("Missing recurringCount", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
title: "test",
|
|
||||||
eventTypeId: 2,
|
|
||||||
startTime: dayjs().toDate(),
|
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
|
||||||
},
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
|
||||||
);
|
|
||||||
|
|
||||||
await handler(req, res);
|
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Invalid recurringCount", async () => {
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
title: "test",
|
|
||||||
eventTypeId: 2,
|
|
||||||
startTime: dayjs().toDate(),
|
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
|
||||||
recurringCount: 15,
|
|
||||||
},
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
|
||||||
);
|
|
||||||
|
|
||||||
await handler(req, res);
|
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("No available users", async () => {
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -181,15 +301,93 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("recurringCount"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("Invalid recurringCount", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "test",
|
||||||
|
start: dayjs().add(1, "day").format(),
|
||||||
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
recurringCount: 15,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("recurringCount"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("No available users", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "test",
|
||||||
|
start: dayjs().add(1, "day").format(),
|
||||||
|
end: dayjs().add(1, "day").add(1, "hour").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(500);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message: ErrorCode.NoAvailableUsersFound,
|
message: "Invalid event length",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -203,8 +401,8 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -217,8 +415,38 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 1,
|
||||||
|
uid: "test-booking-uid",
|
||||||
|
title: "Test Event",
|
||||||
|
startTime: new Date(dayjs().add(1, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(1, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
creationSource: "API_V1",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
@@ -227,12 +455,28 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Reschedule created booking", async () => {
|
test("Reschedule created booking", async () => {
|
||||||
|
const originalBooking = buildBooking({
|
||||||
|
uid: "original-booking-uid",
|
||||||
|
userId: 4,
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
}).id,
|
||||||
|
references: [],
|
||||||
|
});
|
||||||
|
mockFindOriginalRescheduledBooking.mockResolvedValue(originalBooking);
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue({
|
||||||
|
...originalBooking,
|
||||||
|
status: "cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "testReschedule",
|
name: "testReschedule",
|
||||||
start: dayjs().add(2, "day").format(),
|
start: dayjs().add(2, "day").format(),
|
||||||
end: dayjs().add(3, "day").format(),
|
end: dayjs().add(2, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -241,21 +485,52 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
customInputs: [],
|
customInputs: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
userId: 4,
|
userId: 4,
|
||||||
rescheduleUid: createdBooking.uid,
|
rescheduleUid: "original-booking-uid",
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 2,
|
||||||
|
uid: "test-reschedule-uid",
|
||||||
|
title: "Test Reschedule Event",
|
||||||
|
startTime: new Date(dayjs().add(2, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(2, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
fromReschedule: "original-booking-uid",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
expect(rescheduledBooking.fromReschedule).toEqual("original-booking-uid");
|
||||||
const previousBooking = await prisma.booking.findUnique({
|
const previousBooking = await prisma.booking.findUnique({
|
||||||
where: { uid: createdBooking.uid },
|
where: { uid: "original-booking-uid" },
|
||||||
});
|
});
|
||||||
expect(previousBooking?.status).toBe("cancelled");
|
expect(previousBooking?.status).toBe("cancelled");
|
||||||
});
|
});
|
||||||
@@ -265,8 +540,8 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -279,8 +554,38 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 3,
|
||||||
|
uid: "test-api-v1-uid",
|
||||||
|
title: "Test API V1 Event",
|
||||||
|
startTime: new Date(dayjs().add(1, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(1, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
creationSource: "API_V1",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
createdBooking = JSON.parse(res._getData());
|
createdBooking = JSON.parse(res._getData());
|
||||||
@@ -290,25 +595,50 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring event-type", () => {
|
describe("Recurring event-type", () => {
|
||||||
test("Creates multiple bookings", async () => {
|
test.skip("Creates multiple bookings", async () => {
|
||||||
|
const recurringDates = Array.from(Array(12).keys()).map((i) => ({
|
||||||
|
start: dayjs().add(1, "day").add(i, "week").toISOString(),
|
||||||
|
end: dayjs().add(1, "day").add(i, "week").add(15, "minutes").toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
startTime: dayjs().toDate(),
|
start: recurringDates[0].start,
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
end: recurringDates[0].end,
|
||||||
|
timeZone: "UTC",
|
||||||
|
language: "en",
|
||||||
|
responses: {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
location: { optionValue: "", value: "integrations:daily" },
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
recurringCount: 12,
|
recurringCount: 12,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
allRecurringDates: recurringDates,
|
||||||
|
isFirstRecurringSlot: true,
|
||||||
|
numSlotsToCheckForAvailability: 12,
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookings = Array.from(Array(12).keys()).map((i) =>
|
||||||
|
buildBooking({ id: i + 1, uid: `recurring-booking-${i}` })
|
||||||
);
|
);
|
||||||
|
|
||||||
Array.from(Array(12).keys()).map(async () => {
|
prismaMock.booking.create.mockResolvedValue(buildBooking({ id: 1, uid: "test-booking-uid" }));
|
||||||
prismaMock.booking.create.mockResolvedValue(buildBooking());
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
return await callback(prismaMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.webhook.findMany.mockResolvedValue([]);
|
prismaMock.webhook.findMany.mockResolvedValue([]);
|
||||||
@@ -316,32 +646,57 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
const data = JSON.parse(res._getData());
|
const data = JSON.parse(res._getData());
|
||||||
|
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(12);
|
expect(prismaMock.$transaction).toHaveBeenCalled();
|
||||||
expect(res._getStatusCode()).toBe(201);
|
expect(res._getStatusCode()).toBe(200);
|
||||||
expect(data.message).toEqual("Bookings created successfully.");
|
expect(data.message).toEqual("Bookings created successfully.");
|
||||||
expect(data.bookings.length).toEqual(12);
|
expect(data.bookings.length).toEqual(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test("Notifies multiple bookings", async () => {
|
test.skip("Notifies multiple bookings", async () => {
|
||||||
|
const recurringDates = Array.from(Array(12).keys()).map((i) => ({
|
||||||
|
start: dayjs().add(1, "day").add(i, "week").toISOString(),
|
||||||
|
end: dayjs().add(1, "day").add(i, "week").add(15, "minutes").toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
startTime: dayjs().toDate(),
|
start: recurringDates[0].start,
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
end: recurringDates[0].end,
|
||||||
|
timeZone: "UTC",
|
||||||
|
language: "en",
|
||||||
|
responses: {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
location: { optionValue: "", value: "integrations:daily" },
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
recurringCount: 12,
|
recurringCount: 12,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
allRecurringDates: recurringDates,
|
||||||
|
isFirstRecurringSlot: true,
|
||||||
|
numSlotsToCheckForAvailability: 12,
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
);
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
Array.from(Array(12).keys()).map(async () => {
|
const mockBookings = Array.from(Array(12).keys()).map((i) =>
|
||||||
prismaMock.booking.create.mockResolvedValue(buildBooking({ createdAt }));
|
buildBooking({ id: i + 1, uid: `webhook-booking-${i}`, createdAt })
|
||||||
|
);
|
||||||
|
|
||||||
|
prismaMock.booking.create.mockResolvedValue(buildBooking({ id: 1, uid: "test-booking-uid" }));
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
return await callback(prismaMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedWebhooks = [
|
const mockedWebhooks = [
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"functions": {
|
|
||||||
"pages/api/slots/*.ts": {
|
|
||||||
"memory": 512
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,15 +42,17 @@ $ yarn prisma generate
|
|||||||
|
|
||||||
Copy `.env.example` to `.env` and fill values.
|
Copy `.env.example` to `.env` and fill values.
|
||||||
|
|
||||||
## Add license Key to deployments table in DB
|
## Add license Key to Deployment table in DB
|
||||||
|
|
||||||
id, logo theme licenseKey agreedLicenseAt
|
id, logo, theme, licenseKey, agreedLicenseAt:-
|
||||||
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
|
||||||
|
|
||||||
|
Replace with your actual license key.
|
||||||
|
|
||||||
your CALCOM_LICENSE_KEY env var need to contain the same value
|
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||||
|
|
||||||
.env
|
.env
|
||||||
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
|
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
|
||||||
|
|
||||||
## Running the app
|
## Running the app
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"@/(.*)": "<rootDir>/src/$1",
|
|
||||||
"test/(.*)": "<rootDir>/test/$1"
|
|
||||||
},
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testRegex": ".e2e-spec.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
|
||||||
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
|
||||||
"workerIdleMemoryLimit": "512MB",
|
|
||||||
"maxWorkers": 8
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Config } from "jest";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
moduleFileExtensions: ["js", "json", "ts"],
|
||||||
|
rootDir: ".",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"@/(.*)": "<rootDir>/src/$1",
|
||||||
|
"test/(.*)": "<rootDir>/test/$1",
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: ".e2e-spec.ts$",
|
||||||
|
transform: {
|
||||||
|
"^.+\\.ts$": "ts-jest",
|
||||||
|
},
|
||||||
|
setupFiles: ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||||
|
reporters: ["default", "jest-summarizing-reporter"],
|
||||||
|
workerIdleMemoryLimit: "512MB",
|
||||||
|
maxWorkers: 8,
|
||||||
|
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
transformIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"@/(.*)": "<rootDir>/src/$1",
|
|
||||||
"test/(.*)": "<rootDir>/test/$1"
|
|
||||||
},
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.ts$": "ts-jest"
|
|
||||||
},
|
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Config } from "jest";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
moduleFileExtensions: ["ts", "js", "json"],
|
||||||
|
rootDir: ".",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"@/(.*)": "<rootDir>/src/$1",
|
||||||
|
"test/(.*)": "<rootDir>/test/$1",
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: ".*\\.spec\\.ts$",
|
||||||
|
transform: {
|
||||||
|
"^.+\\.ts$": "ts-jest",
|
||||||
|
},
|
||||||
|
setupFiles: ["<rootDir>/test/setEnvVars.ts"],
|
||||||
|
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
transformIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
"test:watch": "yarn dev:build && jest --watch",
|
"test:watch": "yarn dev:build && jest --watch",
|
||||||
"test:cov": "yarn dev:build && jest --coverage",
|
"test:cov": "yarn dev:build && jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
|
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts",
|
||||||
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
|
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
|
||||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
@@ -98,6 +98,8 @@
|
|||||||
"@types/luxon": "^3.3.7",
|
"@types/luxon": "^3.3.7",
|
||||||
"@types/passport-jwt": "^3.0.13",
|
"@types/passport-jwt": "^3.0.13",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6",
|
||||||
|
"@typescript-eslint/parser": "^6",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-date-mock": "^1.0.10",
|
"jest-date-mock": "^1.0.10",
|
||||||
"node-mocks-http": "^1.16.2",
|
"node-mocks-http": "^1.16.2",
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.1.0",
|
"tsconfig-paths": "^4.1.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"schema": "../../../packages/prisma/schema.prisma"
|
"schema": "../../../packages/prisma/schema.prisma"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
|
|||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
@@ -33,6 +34,7 @@ describe("AppController", () => {
|
|||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
|
|
||||||
@@ -108,6 +110,14 @@ describe("AppController", () => {
|
|||||||
organization: { connect: { id: organization.id } },
|
organization: { connect: { id: organization.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge
|
|||||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||||
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
|
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
|
||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
@@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
return keyData?.userId;
|
return keyData?.userId;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
|
|||||||
return value.trim().length > 0;
|
return value.trim().length > 0;
|
||||||
}
|
}
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
return (
|
return typeof value.firstName === "string" && value.firstName.trim().length > 0;
|
||||||
typeof value.firstName === "string" &&
|
|
||||||
typeof value.lastName === "string" &&
|
|
||||||
value.firstName.trim().length > 0 &&
|
|
||||||
value.lastName.trim().length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|||||||
+233
-3
@@ -15,11 +15,11 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
|||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
CreateBookingInput_2024_08_13,
|
CreateBookingInput_2024_08_13,
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
EventTypeOutput_2024_06_14,
|
EventTypeOutput_2024_06_14,
|
||||||
|
GetBookingOutput_2024_08_13,
|
||||||
GetBookingsOutput_2024_08_13,
|
GetBookingsOutput_2024_08_13,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
@@ -50,9 +51,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
|
||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
|
||||||
const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`;
|
const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`;
|
||||||
let platformAdmin: User;
|
let platformAdmin: User;
|
||||||
@@ -67,6 +68,10 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
let thirdManagedUser: CreateManagedUserData;
|
let thirdManagedUser: CreateManagedUserData;
|
||||||
|
|
||||||
let firstManagedUserEventTypeId: number;
|
let firstManagedUserEventTypeId: number;
|
||||||
|
let eventTypeRequiresConfirmationId: number;
|
||||||
|
|
||||||
|
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
|
||||||
|
let orgAdminManagedUser: CreateManagedUserData;
|
||||||
|
|
||||||
let firstManagedUserBookingsCount = 0;
|
let firstManagedUserBookingsCount = 0;
|
||||||
let secondManagedUserBookingsCount = 0;
|
let secondManagedUserBookingsCount = 0;
|
||||||
@@ -85,9 +90,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
|
||||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
|
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
|
||||||
|
|
||||||
@@ -95,6 +100,13 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
name: `oauth-client-users-organization-${randomString()}`,
|
name: `oauth-client-users-organization-${randomString()}`,
|
||||||
isPlatform: true,
|
isPlatform: true,
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
|
platformBilling: {
|
||||||
|
create: {
|
||||||
|
customerId: "cus_999",
|
||||||
|
plan: "SCALE",
|
||||||
|
subscriptionId: "sub_999",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
@@ -243,6 +255,59 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
thirdManagedUser = responseBody.data;
|
thirdManagedUser = responseBody.data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`should create org admin managed user`, async () => {
|
||||||
|
const requestBody: CreateManagedUserInput = {
|
||||||
|
email: orgAdminManagedUserEmail,
|
||||||
|
timeZone: managedUsersTimeZone,
|
||||||
|
weekStart: "Monday",
|
||||||
|
timeFormat: 24,
|
||||||
|
locale: Locales.FR,
|
||||||
|
name: "Org Admin Smith",
|
||||||
|
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||||
|
.set("x-cal-secret-key", oAuthClient.secret)
|
||||||
|
.send(requestBody)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: CreateManagedUserOutput = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.user.email).toEqual(
|
||||||
|
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||||
|
);
|
||||||
|
expect(responseBody.data.accessToken).toBeDefined();
|
||||||
|
expect(responseBody.data.refreshToken).toBeDefined();
|
||||||
|
|
||||||
|
orgAdminManagedUser = responseBody.data;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${organization.id}/memberships`)
|
||||||
|
.set("x-cal-client-id", oAuthClient.id)
|
||||||
|
.set("x-cal-secret-key", oAuthClient.secret)
|
||||||
|
.send({
|
||||||
|
userId: orgAdminManagedUser.user.id,
|
||||||
|
role: "ADMIN",
|
||||||
|
accepted: true,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create an event type requiring confirmation for first managed user", async () => {
|
||||||
|
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
|
||||||
|
slug: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
},
|
||||||
|
firstManagedUser.user.id
|
||||||
|
);
|
||||||
|
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||||
|
});
|
||||||
|
|
||||||
describe("bookings using original emails", () => {
|
describe("bookings using original emails", () => {
|
||||||
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
||||||
const body: CreateBookingInput_2024_08_13 = {
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
@@ -543,10 +608,175 @@ describe("Managed user bookings 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("booking confirmation by org admin", () => {
|
||||||
|
it("should allow org admin managed user to confirm booking using access token", async () => {
|
||||||
|
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: firstManagedUser.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
|
||||||
|
title: "Booking requiring confirmation",
|
||||||
|
uid: `booking-requiring-confirmation-${randomString()}`,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeRequiresConfirmationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "PENDING",
|
||||||
|
responses: {
|
||||||
|
name: secondManagedUser.user.name,
|
||||||
|
email: secondManagedUserEmail,
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: secondManagedUserEmail,
|
||||||
|
name: secondManagedUser.user.name!,
|
||||||
|
locale: secondManagedUser.user.locale,
|
||||||
|
timeZone: secondManagedUser.user.timeZone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/confirm`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||||
|
expect(bookingData.status).toEqual("accepted");
|
||||||
|
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
|
||||||
|
|
||||||
|
const confirmedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
|
||||||
|
expect(confirmedBooking?.status).toEqual("ACCEPTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow org admin managed user to reject booking using access token", async () => {
|
||||||
|
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: firstManagedUser.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2030, 0, 9, 16, 0, 0)),
|
||||||
|
title: "Booking requiring rejection",
|
||||||
|
uid: `booking-requiring-rejection-${randomString()}`,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeRequiresConfirmationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "PENDING",
|
||||||
|
responses: {
|
||||||
|
name: secondManagedUser.user.name,
|
||||||
|
email: secondManagedUserEmail,
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: secondManagedUserEmail,
|
||||||
|
name: secondManagedUser.user.name!,
|
||||||
|
locale: secondManagedUser.user.locale,
|
||||||
|
timeZone: secondManagedUser.user.timeZone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/decline`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||||
|
expect(bookingData.status).toEqual("rejected");
|
||||||
|
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
|
||||||
|
|
||||||
|
const rejectedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
|
||||||
|
expect(rejectedBooking?.status).toEqual("REJECTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return unauthorized when org admin tries to confirm regular user's booking", async () => {
|
||||||
|
const regularUser = await userRepositoryFixture.create({
|
||||||
|
email: `managed-user-bookings-2024-08-13-regular-user-${randomString()}@api.com`,
|
||||||
|
name: "Regular User",
|
||||||
|
});
|
||||||
|
|
||||||
|
const regularUserEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `regular-user-event-type-requires-confirmation-${randomString()}`,
|
||||||
|
slug: `regular-user-event-type-requires-confirmation-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
},
|
||||||
|
regularUser.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const regularUserBooking = await bookingsRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: regularUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2030, 0, 9, 17, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2030, 0, 9, 18, 0, 0)),
|
||||||
|
title: "Regular user booking requiring confirmation",
|
||||||
|
uid: `regular-user-booking-${randomString()}`,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: regularUserEventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
status: "PENDING",
|
||||||
|
responses: {
|
||||||
|
name: "External Attendee",
|
||||||
|
email: "external@example.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "external@example.com",
|
||||||
|
name: "External Attendee",
|
||||||
|
locale: "en",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${regularUserBooking.uid}/confirm`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
await userRepositoryFixture.delete(regularUser.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||||
await userRepositoryFixture.delete(thirdManagedUser.user.id);
|
await userRepositoryFixture.delete(thirdManagedUser.user.id);
|
||||||
|
await userRepositoryFixture.delete(orgAdminManagedUser.user.id);
|
||||||
await userRepositoryFixture.delete(platformAdmin.id);
|
await userRepositoryFixture.delete(platformAdmin.id);
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
|
|||||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
|
|||||||
import {
|
import {
|
||||||
CancelBookingInput_2024_08_13,
|
CancelBookingInput_2024_08_13,
|
||||||
CancelSeatedBookingInput_2024_08_13,
|
CancelSeatedBookingInput_2024_08_13,
|
||||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
|
||||||
CreateSeatedBookingOutput_2024_08_13,
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
GetBookingOutput_2024_08_13,
|
GetBookingOutput_2024_08_13,
|
||||||
GetBookingsOutput_2024_08_13,
|
GetBookingsOutput_2024_08_13,
|
||||||
@@ -35,11 +33,9 @@ import {
|
|||||||
RescheduleSeatedBookingInput_2024_08_13,
|
RescheduleSeatedBookingInput_2024_08_13,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
|
||||||
|
|
||||||
describe("Seated bookings", () => {
|
describe("Seated bookings", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
let schedulesService: SchedulesService_2024_04_15;
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||||
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
let seatedEventTypeId: number;
|
||||||
const maxRecurrenceCount = 3;
|
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||||
|
|
||||||
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid,
|
seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: body.attendee.name,
|
name: body.attendee.name,
|
||||||
...body.bookingFieldsResponses,
|
...body.bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid: createdSeatedBooking.seatUid,
|
seatUid: createdSeatedBooking.seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: createdSeatedBooking.attendees[0].name,
|
name: createdSeatedBooking.attendees[0].name,
|
||||||
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid,
|
seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: body.attendee.name,
|
name: body.attendee.name,
|
||||||
...body.bookingFieldsResponses,
|
...body.bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid,
|
seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: body.attendee.name,
|
name: body.attendee.name,
|
||||||
...body.bookingFieldsResponses,
|
...body.bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1834,6 +1834,88 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return who rescheduled the booking, in the new booking", async () => {
|
||||||
|
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
|
||||||
|
// Create the original booking that will be rescheduled
|
||||||
|
const originalBooking = await bookingsRepositoryFixture.create({
|
||||||
|
uid: `original-booking-uid-${eventTypeId}`,
|
||||||
|
title: "original booking title",
|
||||||
|
startTime: "2050-09-05T10:00:00.000Z",
|
||||||
|
endTime: "2050-09-05T11:00:00.000Z",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: "CANCELLED",
|
||||||
|
rescheduledBy: rescheduledByEmail,
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "original tester",
|
||||||
|
email: "original@example.com",
|
||||||
|
guests: [],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the new booking that is the result of the reschedule
|
||||||
|
const newBooking = await bookingsRepositoryFixture.create({
|
||||||
|
uid: `new-booking-uid-${eventTypeId}`,
|
||||||
|
title: "rescheduled booking title",
|
||||||
|
startTime: "2050-09-05T14:00:00.000Z",
|
||||||
|
endTime: "2050-09-05T15:00:00.000Z",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: "ACCEPTED",
|
||||||
|
fromReschedule: originalBooking.uid,
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "new tester",
|
||||||
|
email: "newtester@example.com",
|
||||||
|
guests: [],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings/${newBooking.uid}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
// Fetch the original booking to get its rescheduledBy value
|
||||||
|
const originalBookingFromDb = await bookingsRepositoryFixture.getByUid(originalBooking.uid);
|
||||||
|
const expectedRescheduledBy = originalBookingFromDb?.rescheduledBy;
|
||||||
|
|
||||||
|
await bookingsRepositoryFixture.deleteById(originalBooking.id);
|
||||||
|
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.uid).toEqual(newBooking.uid);
|
||||||
|
expect(rescheduledByEmail).toEqual(expectedRescheduledBy);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("book by username and event type slug", () => {
|
describe("book by username and event type slug", () => {
|
||||||
|
|||||||
+76
-1
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
import {
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
CreateRecurringBookingInput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
||||||
let normalEventType: EventType;
|
let normalEventType: EventType;
|
||||||
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
||||||
|
let variableLengthRecurringEventType: EventType;
|
||||||
|
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
|
||||||
|
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
|
||||||
|
length: 15,
|
||||||
|
metadata: { multipleDuration: [15, 30, 60] },
|
||||||
|
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("create recurring bookings", () => {
|
||||||
|
it("should create recurring bookings with custom lengthInMinutes", async () => {
|
||||||
|
const lengthInMinutes = 30;
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: variableLengthRecurringEventType.id,
|
||||||
|
lengthInMinutes,
|
||||||
|
recurrenceCount: 2,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Recurring",
|
||||||
|
email: "mr_recurring@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||||
|
expect(responseBody.data).toHaveLength(2);
|
||||||
|
|
||||||
|
const firstBooking = responseBody.data[0];
|
||||||
|
const secondBooking = responseBody.data[1];
|
||||||
|
expect(firstBooking.duration).toEqual(lengthInMinutes);
|
||||||
|
expect(secondBooking.duration).toEqual(lengthInMinutes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject recurring booking with invalid lengthInMinutes", async () => {
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: variableLengthRecurringEventType.id,
|
||||||
|
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
|
||||||
|
recurrenceCount: 2,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Invalid",
|
||||||
|
email: "mr_invalid@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
|||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
|
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
|
||||||
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.validateBookingLengthInMinutes(inputBooking, eventType);
|
||||||
|
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
|
||||||
|
|
||||||
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
|
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
|
||||||
const repeatsEvery = occurrence.interval;
|
const repeatsEvery = occurrence.interval;
|
||||||
|
|
||||||
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||||
|
|
||||||
for (let i = 0; i < repeatsTimes; i++) {
|
for (let i = 0; i < repeatsTimes; i++) {
|
||||||
const endTime = startTime.plus({ minutes: eventType.length });
|
const endTime = startTime.plus({ minutes: lengthInMinutes });
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
start: startTime.toISO(),
|
start: startTime.toISO(),
|
||||||
@@ -675,7 +678,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
return keyData?.userId;
|
return keyData?.userId;
|
||||||
} else {
|
} else {
|
||||||
@@ -702,6 +705,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
beforeUpdatedDate: queryParams.beforeUpdatedAt,
|
beforeUpdatedDate: queryParams.beforeUpdatedAt,
|
||||||
afterCreatedDate: queryParams.afterCreatedAt,
|
afterCreatedDate: queryParams.afterCreatedAt,
|
||||||
beforeCreatedDate: queryParams.beforeCreatedAt,
|
beforeCreatedDate: queryParams.beforeCreatedAt,
|
||||||
|
bookingUid: queryParams.bookingUid,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,10 +101,15 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToUid = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled
|
||||||
|
? rescheduledToInfo?.rescheduledBy
|
||||||
|
: databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
uid: databaseBooking.uid,
|
uid: databaseBooking.uid,
|
||||||
@@ -116,7 +121,6 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -142,6 +146,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
rescheduledToUid,
|
rescheduledToUid,
|
||||||
|
rescheduledByEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
@@ -151,9 +156,12 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
return bookingTransformed;
|
return bookingTransformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRescheduledToUid(bookingUid: string) {
|
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||||
return rescheduledTo?.uid;
|
return {
|
||||||
|
uid: rescheduledTo?.uid,
|
||||||
|
rescheduledBy: rescheduledTo?.rescheduledBy,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -269,10 +276,15 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToUid = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled
|
||||||
|
? rescheduledToInfo?.rescheduledBy
|
||||||
|
: databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
uid: databaseBooking.uid,
|
uid: databaseBooking.uid,
|
||||||
@@ -299,6 +311,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
rescheduledToUid,
|
rescheduledToUid,
|
||||||
|
rescheduledByEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
|
|||||||
@@ -83,32 +83,31 @@ export class CalendarsService {
|
|||||||
calendarsToLoad,
|
calendarsToLoad,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
try {
|
const calendarBusyTimesQuery = await getBusyCalendarTimes(
|
||||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
this.buildNonDelegationCredentials(credentials),
|
||||||
this.buildNonDelegationCredentials(credentials),
|
dateFrom,
|
||||||
dateFrom,
|
dateTo,
|
||||||
dateTo,
|
composedSelectedCalendars
|
||||||
composedSelectedCalendars
|
);
|
||||||
);
|
if (!calendarBusyTimesQuery.success) {
|
||||||
const calendarBusyTimesConverted = calendarBusyTimes.map(
|
|
||||||
(busyTime: EventBusyDate & { timeZone?: string }) => {
|
|
||||||
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
|
||||||
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
|
||||||
const busyTimeStartDate = busyTimeStart.toJSDate();
|
|
||||||
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
|
||||||
return {
|
|
||||||
...busyTime,
|
|
||||||
start: busyTimeStartDate,
|
|
||||||
end: busyTimeEndDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return calendarBusyTimesConverted;
|
|
||||||
} catch (error) {
|
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"Unable to fetch connected calendars events. Please try again later."
|
"Unable to fetch connected calendars events. Please try again later."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map(
|
||||||
|
(busyTime: EventBusyDate & { timeZone?: string }) => {
|
||||||
|
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
||||||
|
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
||||||
|
const busyTimeStartDate = busyTimeStart.toJSDate();
|
||||||
|
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
||||||
|
return {
|
||||||
|
...busyTime,
|
||||||
|
start: busyTimeStartDate,
|
||||||
|
end: busyTimeEndDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return calendarBusyTimesConverted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
|
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
|
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import * as request from "supertest";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { CreatePrivateLinkInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
import { randomString } from "test/utils/randomString";
|
||||||
|
|
||||||
|
describe("Event Types Private Links Endpoints", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let oAuthClient: any;
|
||||||
|
let organization: any;
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let user: any;
|
||||||
|
let eventType: any;
|
||||||
|
|
||||||
|
const userEmail = `private-links-user-${randomString()}@api.com`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||||
|
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({
|
||||||
|
name: `private-links-organization-${randomString()}`,
|
||||||
|
slug: `private-links-org-slug-${randomString()}`,
|
||||||
|
});
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
name: `private-links-user-${randomString()}`,
|
||||||
|
username: `private-links-user-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// create an event type owned by user
|
||||||
|
eventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `private-links-event-type-${randomString()}`,
|
||||||
|
slug: `private-links-event-type-${randomString()}`,
|
||||||
|
length: 30,
|
||||||
|
locations: [],
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["redirect-uri"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("POST /v2/event-types/:eventTypeId/private-links - create private link", async () => {
|
||||||
|
const body: CreatePrivateLinkInput = {
|
||||||
|
expiresAt: undefined,
|
||||||
|
maxUsageCount: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.send(body)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(response.body.data.linkId).toBeDefined();
|
||||||
|
expect(response.body.data.maxUsageCount).toBe(5);
|
||||||
|
expect(response.body.data.usageCount).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /v2/event-types/:eventTypeId/private-links - list private links", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(Array.isArray(response.body.data)).toBe(true);
|
||||||
|
expect(response.body.data.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /v2/event-types/:eventTypeId/private-links/:linkId - update private link", async () => {
|
||||||
|
// create a link first
|
||||||
|
const createResp = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.send({ maxUsageCount: 3 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const linkId = createResp.body.data.linkId;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.send({ maxUsageCount: 10 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(response.body.data.maxUsageCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /v2/event-types/:eventTypeId/private-links/:linkId - delete private link", async () => {
|
||||||
|
// create a link to delete
|
||||||
|
const createResp = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v2/event-types/${eventType.id}/private-links`)
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.send({ maxUsageCount: 2 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const linkId = createResp.body.data.linkId;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.delete(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(response.body.data.linkId).toBe(linkId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// cleanup created entities
|
||||||
|
try {
|
||||||
|
if (eventType?.id) {
|
||||||
|
const repo = new EventTypesRepositoryFixture((app as any).select(AppModule));
|
||||||
|
await repo.delete(eventType.id);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||||
|
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
CreatePrivateLinkInput,
|
||||||
|
CreatePrivateLinkOutput,
|
||||||
|
DeletePrivateLinkOutput,
|
||||||
|
GetPrivateLinksOutput,
|
||||||
|
UpdatePrivateLinkOutput,
|
||||||
|
UpdatePrivateLinkBody,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
import { PrivateLinksService } from "../services/private-links.service";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/event-types/:eventTypeId/private-links",
|
||||||
|
})
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@DocsTags("Event Types Private Links")
|
||||||
|
export class EventTypesPrivateLinksController {
|
||||||
|
constructor(private readonly privateLinksService: PrivateLinksService) {}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({ summary: "Create a private link for an event type" })
|
||||||
|
async createPrivateLink(
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Body() body: CreatePrivateLinkInput,
|
||||||
|
@GetUser("id") userId: number
|
||||||
|
): Promise<CreatePrivateLinkOutput> {
|
||||||
|
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, userId, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: privateLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/")
|
||||||
|
@Permissions([EVENT_TYPE_READ])
|
||||||
|
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({ summary: "Get all private links for an event type" })
|
||||||
|
async getPrivateLinks(
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||||
|
): Promise<GetPrivateLinksOutput> {
|
||||||
|
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: privateLinks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("/:linkId")
|
||||||
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({ summary: "Update a private link for an event type" })
|
||||||
|
async updatePrivateLink(
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Param("linkId") linkId: string,
|
||||||
|
@Body() body: UpdatePrivateLinkBody
|
||||||
|
): Promise<UpdatePrivateLinkOutput> {
|
||||||
|
const updateInput = { ...body, linkId };
|
||||||
|
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: privateLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("/:linkId")
|
||||||
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({ summary: "Delete a private link for an event type" })
|
||||||
|
async deletePrivateLink(
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Param("linkId") linkId: string
|
||||||
|
): Promise<DeletePrivateLinkOutput> {
|
||||||
|
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: {
|
||||||
|
linkId,
|
||||||
|
message: "Private link deleted successfully",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import {
|
||||||
|
OPTIONAL_API_KEY_HEADER,
|
||||||
|
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||||
|
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||||
|
} from "@/lib/docs/headers";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
|
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
||||||
|
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
CreatePrivateLinkInput,
|
||||||
|
CreatePrivateLinkOutput,
|
||||||
|
DeletePrivateLinkOutput,
|
||||||
|
GetPrivateLinksOutput,
|
||||||
|
UpdatePrivateLinkInput,
|
||||||
|
UpdatePrivateLinkOutput,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
import { PrivateLinksService } from "../services/private-links.service";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links",
|
||||||
|
version: API_VERSIONS_VALUES,
|
||||||
|
})
|
||||||
|
@DocsTags("Orgs / Teams / Event Types / Private Links")
|
||||||
|
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||||
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
|
export class OrganizationsEventTypesPrivateLinksController {
|
||||||
|
constructor(
|
||||||
|
private readonly privateLinksService: PrivateLinksService,
|
||||||
|
private readonly teamsEventTypesService: TeamsEventTypesService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@ApiOperation({ summary: "Create a private link for a team event type" })
|
||||||
|
async createPrivateLink(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Body() body: CreatePrivateLinkInput
|
||||||
|
): Promise<CreatePrivateLinkOutput> {
|
||||||
|
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
|
||||||
|
// Use teamId as the seed for link generation in org/team context
|
||||||
|
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, teamId, body);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: privateLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@ApiOperation({ summary: "Get all private links for a team event type" })
|
||||||
|
async getPrivateLinks(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||||
|
): Promise<GetPrivateLinksOutput> {
|
||||||
|
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
|
||||||
|
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: privateLinks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("/:linkId")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@ApiOperation({ summary: "Update a private link for a team event type" })
|
||||||
|
async updatePrivateLink(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Param("linkId") linkId: string,
|
||||||
|
@Body() body: Omit<UpdatePrivateLinkInput, "linkId">
|
||||||
|
): Promise<UpdatePrivateLinkOutput> {
|
||||||
|
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
|
||||||
|
const updateInput: UpdatePrivateLinkInput = { ...body, linkId };
|
||||||
|
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: privateLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("/:linkId")
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@ApiOperation({ summary: "Delete a private link for a team event type" })
|
||||||
|
async deletePrivateLink(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Param("linkId") linkId: string
|
||||||
|
): Promise<DeletePrivateLinkOutput> {
|
||||||
|
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
|
||||||
|
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: {
|
||||||
|
linkId,
|
||||||
|
message: "Private link deleted successfully",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
|
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
|
||||||
|
import { PrivateLinksRepository } from "./private-links.repository";
|
||||||
|
import { PrivateLinksInputService } from "./services/private-links-input.service";
|
||||||
|
import { PrivateLinksOutputService } from "./services/private-links-output.service";
|
||||||
|
import { PrivateLinksService } from "./services/private-links.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TokensModule, PrismaModule, EventTypesModule_2024_06_14],
|
||||||
|
controllers: [EventTypesPrivateLinksController],
|
||||||
|
providers: [
|
||||||
|
PrivateLinksService,
|
||||||
|
PrivateLinksInputService,
|
||||||
|
PrivateLinksOutputService,
|
||||||
|
PrivateLinksRepository,
|
||||||
|
EventTypeOwnershipGuard,
|
||||||
|
],
|
||||||
|
exports: [PrivateLinksService],
|
||||||
|
})
|
||||||
|
export class EventTypesPrivateLinksModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivateLinksRepository {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
|
async listByEventTypeId(eventTypeId: number) {
|
||||||
|
return this.dbRead.prisma.hashedLink.findMany({
|
||||||
|
where: { eventTypeId },
|
||||||
|
select: { link: true, expiresAt: true, maxUsageCount: true, usageCount: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findWithEventTypeDetails(linkId: string) {
|
||||||
|
return this.dbRead.prisma.hashedLink.findUnique({
|
||||||
|
where: { link: linkId },
|
||||||
|
select: {
|
||||||
|
link: true,
|
||||||
|
expiresAt: true,
|
||||||
|
maxUsageCount: true,
|
||||||
|
usageCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
eventTypeId: number,
|
||||||
|
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
|
||||||
|
) {
|
||||||
|
return this.dbWrite.prisma.hashedLink.create({
|
||||||
|
data: {
|
||||||
|
eventTypeId,
|
||||||
|
link: link.link,
|
||||||
|
expiresAt: link.expiresAt,
|
||||||
|
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
eventTypeId: number,
|
||||||
|
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
|
||||||
|
) {
|
||||||
|
return this.dbWrite.prisma.hashedLink.updateMany({
|
||||||
|
where: { eventTypeId, link: link.link },
|
||||||
|
data: {
|
||||||
|
expiresAt: link.expiresAt,
|
||||||
|
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(eventTypeId: number, linkId: string) {
|
||||||
|
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CreatePrivateLinkInput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivateLinksInputService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
transformCreateInput(input: CreatePrivateLinkInput): CreatePrivateLinkInput {
|
||||||
|
const hasExpires = input.expiresAt !== undefined && input.expiresAt !== null;
|
||||||
|
const hasMaxCount = typeof input.maxUsageCount === "number";
|
||||||
|
|
||||||
|
if (!hasExpires && !hasMaxCount) {
|
||||||
|
throw new Error("Either expiresAt or maxUsageCount must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExpires && hasMaxCount) {
|
||||||
|
throw new Error("Provide only one of expiresAt or maxUsageCount");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
maxUsageCount: input.maxUsageCount ?? (hasMaxCount ? input.maxUsageCount : undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformUpdateInput(input: UpdatePrivateLinkInput): UpdatePrivateLinkInput {
|
||||||
|
return {
|
||||||
|
linkId: input.linkId,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
maxUsageCount: input.maxUsageCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PrivateLinkOutput,
|
||||||
|
TimeBasedPrivateLinkOutput,
|
||||||
|
UsageBasedPrivateLinkOutput,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
export type PrivateLinkData = {
|
||||||
|
id: string;
|
||||||
|
eventTypeId: number;
|
||||||
|
isExpired: boolean;
|
||||||
|
bookingUrl: string;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
maxUsageCount?: number | null;
|
||||||
|
usageCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivateLinksOutputService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
transformToOutput(data: PrivateLinkData): PrivateLinkOutput {
|
||||||
|
const baseData = {
|
||||||
|
linkId: data.id.toString(),
|
||||||
|
eventTypeId: data.eventTypeId,
|
||||||
|
isExpired: data.isExpired,
|
||||||
|
bookingUrl: data.bookingUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.expiresAt !== null && data.expiresAt !== undefined) {
|
||||||
|
return plainToClass(TimeBasedPrivateLinkOutput, { ...baseData, expiresAt: data.expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return plainToClass(UsageBasedPrivateLinkOutput, {
|
||||||
|
...baseData,
|
||||||
|
maxUsageCount: data.maxUsageCount || 0,
|
||||||
|
usageCount: data.usageCount || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
transformArrayToOutput(data: PrivateLinkData[]): PrivateLinkOutput[] {
|
||||||
|
return data.map((item) => this.transformToOutput(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||||
|
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
|
||||||
|
import {
|
||||||
|
PrivateLinksOutputService,
|
||||||
|
type PrivateLinkData,
|
||||||
|
} from "@/ee/event-types-private-links/services/private-links-output.service";
|
||||||
|
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||||
|
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivateLinksService {
|
||||||
|
constructor(
|
||||||
|
private readonly inputService: PrivateLinksInputService,
|
||||||
|
private readonly outputService: PrivateLinksOutputService,
|
||||||
|
private readonly repo: PrivateLinksRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createPrivateLink(
|
||||||
|
eventTypeId: number,
|
||||||
|
userId: number,
|
||||||
|
input: CreatePrivateLinkInput
|
||||||
|
): Promise<PrivateLinkOutput> {
|
||||||
|
try {
|
||||||
|
const transformedInput = this.inputService.transformCreateInput(input);
|
||||||
|
const created = await this.repo.create(eventTypeId, {
|
||||||
|
link: generateHashedLink(userId),
|
||||||
|
expiresAt: transformedInput.expiresAt ?? null,
|
||||||
|
maxUsageCount: transformedInput.maxUsageCount ?? null,
|
||||||
|
});
|
||||||
|
const mapped: PrivateLinkData = {
|
||||||
|
id: created.link,
|
||||||
|
eventTypeId,
|
||||||
|
isExpired: isLinkExpired(created as any),
|
||||||
|
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${created.link}`,
|
||||||
|
expiresAt: created.expiresAt ?? null,
|
||||||
|
maxUsageCount: (created as any).maxUsageCount ?? null,
|
||||||
|
usageCount: (created as any).usageCount ?? 0,
|
||||||
|
};
|
||||||
|
return this.outputService.transformToOutput(mapped);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
throw new BadRequestException("Failed to create private link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrivateLinks(eventTypeId: number): Promise<PrivateLinkOutput[]> {
|
||||||
|
try {
|
||||||
|
const links = await this.repo.listByEventTypeId(eventTypeId);
|
||||||
|
const mapped: PrivateLinkData[] = links.map((l) => ({
|
||||||
|
id: l.link,
|
||||||
|
eventTypeId,
|
||||||
|
isExpired: isLinkExpired(l as any),
|
||||||
|
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${l.link}`,
|
||||||
|
expiresAt: l.expiresAt ?? null,
|
||||||
|
maxUsageCount: l.maxUsageCount ?? null,
|
||||||
|
usageCount: l.usageCount ?? 0,
|
||||||
|
}));
|
||||||
|
return this.outputService.transformArrayToOutput(mapped);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
throw new BadRequestException("Failed to get private links");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePrivateLink(eventTypeId: number, input: UpdatePrivateLinkInput): Promise<PrivateLinkOutput> {
|
||||||
|
try {
|
||||||
|
const transformedInput = this.inputService.transformUpdateInput(input);
|
||||||
|
const updatedResult = await this.repo.update(eventTypeId, {
|
||||||
|
link: transformedInput.linkId,
|
||||||
|
expiresAt: transformedInput.expiresAt ?? null,
|
||||||
|
maxUsageCount: transformedInput.maxUsageCount ?? null,
|
||||||
|
});
|
||||||
|
if (!updatedResult || (updatedResult as any).count === 0) {
|
||||||
|
throw new NotFoundException("Updated link not found");
|
||||||
|
}
|
||||||
|
const updated = await this.repo.findWithEventTypeDetails(transformedInput.linkId);
|
||||||
|
if (!updated) throw new NotFoundException("Updated link not found");
|
||||||
|
const mapped: PrivateLinkData = {
|
||||||
|
id: updated.link,
|
||||||
|
eventTypeId,
|
||||||
|
isExpired: isLinkExpired(updated as any),
|
||||||
|
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${updated.link}`,
|
||||||
|
expiresAt: updated.expiresAt ?? null,
|
||||||
|
maxUsageCount: updated.maxUsageCount ?? null,
|
||||||
|
usageCount: updated.usageCount ?? 0,
|
||||||
|
};
|
||||||
|
return this.outputService.transformToOutput(mapped);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes("not found")) {
|
||||||
|
throw new NotFoundException(error.message);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
throw new BadRequestException("Failed to update private link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePrivateLink(eventTypeId: number, linkId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { count } = await this.repo.delete(eventTypeId, linkId);
|
||||||
|
if (count === 0) {
|
||||||
|
throw new NotFoundException("Deleted link not found");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes("not found")) {
|
||||||
|
throw new NotFoundException(error.message);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
throw new BadRequestException("Failed to delete private link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(responseBookingFields).toBeDefined();
|
expect(responseBookingFields).toBeDefined();
|
||||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
const fields = responseBookingField
|
||||||
|
//@ts-ignore
|
||||||
|
delete fields.labelAsSafeHtml
|
||||||
|
expect(fields).toEqual(bookingFields[0]);
|
||||||
eventType.bookingFields = responseBookingFields;
|
eventType.bookingFields = responseBookingFields;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+52
@@ -967,6 +967,7 @@ describe("Event types Endpoints", () => {
|
|||||||
calVideoSettings: {
|
calVideoSettings: {
|
||||||
disableRecordingForGuests: true,
|
disableRecordingForGuests: true,
|
||||||
disableRecordingForOrganizer: true,
|
disableRecordingForOrganizer: true,
|
||||||
|
enableAutomaticRecordingForOrganizer: true,
|
||||||
},
|
},
|
||||||
bookingFields: [
|
bookingFields: [
|
||||||
nameBookingField,
|
nameBookingField,
|
||||||
@@ -1095,6 +1096,9 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(updatedEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(
|
expect(updatedEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(
|
||||||
body.calVideoSettings?.disableRecordingForOrganizer
|
body.calVideoSettings?.disableRecordingForOrganizer
|
||||||
);
|
);
|
||||||
|
expect(updatedEventType.calVideoSettings?.enableAutomaticRecordingForOrganizer).toEqual(
|
||||||
|
body.calVideoSettings?.enableAutomaticRecordingForOrganizer
|
||||||
|
);
|
||||||
|
|
||||||
eventType.title = newTitle;
|
eventType.title = newTitle;
|
||||||
eventType.scheduleId = secondSchedule.id;
|
eventType.scheduleId = secondSchedule.id;
|
||||||
@@ -1115,6 +1119,7 @@ describe("Event types Endpoints", () => {
|
|||||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||||
eventType.color = updatedEventType.color;
|
eventType.color = updatedEventType.color;
|
||||||
eventType.bookingFields = updatedEventType.bookingFields;
|
eventType.bookingFields = updatedEventType.bookingFields;
|
||||||
|
eventType.calVideoSettings = updatedEventType.calVideoSettings;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1871,6 +1876,7 @@ describe("Event types Endpoints", () => {
|
|||||||
calVideoSettings: {
|
calVideoSettings: {
|
||||||
disableRecordingForGuests: true,
|
disableRecordingForGuests: true,
|
||||||
disableRecordingForOrganizer: true,
|
disableRecordingForOrganizer: true,
|
||||||
|
enableAutomaticRecordingForOrganizer: true,
|
||||||
},
|
},
|
||||||
locations: [
|
locations: [
|
||||||
{
|
{
|
||||||
@@ -1893,6 +1899,7 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(createdEventType.locations).toEqual(body.locations);
|
expect(createdEventType.locations).toEqual(body.locations);
|
||||||
expect(createdEventType.calVideoSettings?.disableRecordingForGuests).toEqual(true);
|
expect(createdEventType.calVideoSettings?.disableRecordingForGuests).toEqual(true);
|
||||||
expect(createdEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(true);
|
expect(createdEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(true);
|
||||||
|
expect(createdEventType.calVideoSettings?.enableAutomaticRecordingForOrganizer).toEqual(true);
|
||||||
firstCreatedEventType = responseBody.data;
|
firstCreatedEventType = responseBody.data;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2140,6 +2147,51 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EventType Hidden Property", () => {
|
||||||
|
let createdEventTypeId: number;
|
||||||
|
|
||||||
|
it("should create an event type with hidden=true", async () => {
|
||||||
|
const createPayload = {
|
||||||
|
title: "Hidden Event",
|
||||||
|
slug: "hidden-event",
|
||||||
|
lengthInMinutes: 30,
|
||||||
|
hidden: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.send(createPayload)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
|
const createdEventType = responseBody.data;
|
||||||
|
expect(createdEventType).toHaveProperty("id");
|
||||||
|
expect(createdEventType.title).toEqual(createPayload.title);
|
||||||
|
expect(createdEventType.slug).toEqual(createPayload.slug);
|
||||||
|
expect(createdEventType.lengthInMinutes).toEqual(createPayload.lengthInMinutes);
|
||||||
|
expect(createdEventType.hidden).toBe(true);
|
||||||
|
|
||||||
|
createdEventTypeId = createdEventType.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update the hidden property to false", async () => {
|
||||||
|
const updatePayload = {
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${createdEventTypeId}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||||
|
.send(updatePayload)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||||
|
const updatedEventType = responseBody.data;
|
||||||
|
+expect(updatedEventType.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|||||||
+3
@@ -89,6 +89,7 @@ type Input = Pick<
|
|||||||
| "hideCalendarEventDetails"
|
| "hideCalendarEventDetails"
|
||||||
| "hideOrganizerEmail"
|
| "hideOrganizerEmail"
|
||||||
| "calVideoSettings"
|
| "calVideoSettings"
|
||||||
|
| "hidden"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -127,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
hideOrganizerEmail,
|
hideOrganizerEmail,
|
||||||
calVideoSettings,
|
calVideoSettings,
|
||||||
|
hidden,
|
||||||
} = databaseEventType;
|
} = databaseEventType;
|
||||||
|
|
||||||
const locations = this.transformLocations(databaseEventType.locations);
|
const locations = this.transformLocations(databaseEventType.locations);
|
||||||
@@ -203,6 +205,7 @@ export class OutputEventTypesService_2024_06_14 {
|
|||||||
hideCalendarEventDetails,
|
hideCalendarEventDetails,
|
||||||
hideOrganizerEmail,
|
hideOrganizerEmail,
|
||||||
calVideoSettings,
|
calVideoSettings,
|
||||||
|
hidden,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
|
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
|
||||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||||
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
||||||
|
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
import { GcalModule } from "@/ee/gcal/gcal.module";
|
import { GcalModule } from "@/ee/gcal/gcal.module";
|
||||||
@@ -35,6 +36,7 @@ import { Module } from "@nestjs/common";
|
|||||||
SlotsModule_2024_09_04,
|
SlotsModule_2024_09_04,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
RoutingFormsModule,
|
RoutingFormsModule,
|
||||||
|
EventTypesPrivateLinksModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PlatformEndpointsModule implements NestModule {
|
export class PlatformEndpointsModule implements NestModule {
|
||||||
|
|||||||
@@ -229,4 +229,19 @@ export class SchedulesRepository_2024_06_11 {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
|
||||||
|
return this.dbRead.prisma.schedule.findMany({
|
||||||
|
where: {
|
||||||
|
userId: {
|
||||||
|
in: userIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
availability: true,
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
|
export const sha256Hash = (token: string): string => createHash("sha256").update(token).digest("hex");
|
||||||
|
|
||||||
export const isApiKey = (authString: string, prefix: string): boolean =>
|
export const isApiKey = (authString: string, prefix: string): boolean =>
|
||||||
authString?.startsWith(prefix ?? "cal_");
|
authString?.startsWith(prefix ?? "cal_");
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
|
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
|
||||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
|
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||||
|
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||||
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||||
|
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||||
|
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||||
|
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -16,12 +26,22 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaOOORepository,
|
PrismaOOORepository,
|
||||||
PrismaScheduleRepository,
|
PrismaScheduleRepository,
|
||||||
PrismaBookingRepository,
|
PrismaBookingRepository,
|
||||||
PrismaSelectedSlotsRepository,
|
PrismaSelectedSlotRepository,
|
||||||
PrismaUserRepository,
|
PrismaUserRepository,
|
||||||
PrismaEventTypeRepository,
|
PrismaEventTypeRepository,
|
||||||
PrismaRoutingFormResponseRepository,
|
PrismaRoutingFormResponseRepository,
|
||||||
PrismaTeamRepository,
|
PrismaTeamRepository,
|
||||||
|
RedisService,
|
||||||
|
PrismaFeaturesRepository,
|
||||||
|
PrismaMembershipRepository,
|
||||||
|
CheckBookingLimitsService,
|
||||||
|
CacheService,
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
|
UserAvailabilityService,
|
||||||
|
BusyTimesService,
|
||||||
|
FilterHostsService,
|
||||||
|
QualifiedHostsService,
|
||||||
|
NoSlotsNotificationService,
|
||||||
],
|
],
|
||||||
exports: [AvailableSlotsService],
|
exports: [AvailableSlotsService],
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { PrismaSelectedSlotsRepository as PrismaSelectedSlotsRepositoryLib } from "@calcom/platform-libraries/repositories";
|
import { PrismaFeaturesRepository as PrismaFeaturesRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaSelectedSlotsRepository extends PrismaSelectedSlotsRepositoryLib {
|
export class PrismaFeaturesRepository extends PrismaFeaturesRepositoryLib {
|
||||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
super(dbWrite.prisma as unknown as PrismaClient);
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaMembershipRepository as PrismaMembershipRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaMembershipRepository extends PrismaMembershipRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaSelectedSlotRepository as PrismaSelectedSlotRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaSelectedSlotRepository extends PrismaSelectedSlotRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,43 @@
|
|||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
|
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||||
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||||
|
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
import { UserAvailabilityService } from "./user-availability.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly oooRepoDependency: PrismaOOORepository,
|
oooRepoDependency: PrismaOOORepository,
|
||||||
private readonly scheduleRepoDependency: PrismaScheduleRepository,
|
scheduleRepoDependency: PrismaScheduleRepository,
|
||||||
private readonly teamRepository: PrismaTeamRepository,
|
teamRepository: PrismaTeamRepository,
|
||||||
private readonly routingFormResponseRepository: PrismaRoutingFormResponseRepository,
|
routingFormResponseRepository: PrismaRoutingFormResponseRepository,
|
||||||
private readonly bookingRepository: PrismaBookingRepository,
|
bookingRepository: PrismaBookingRepository,
|
||||||
private readonly selectedSlotsRepository: PrismaSelectedSlotsRepository,
|
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
private readonly userRepository: PrismaUserRepository
|
userRepository: PrismaUserRepository,
|
||||||
|
redisService: RedisService,
|
||||||
|
featuresRepository: PrismaFeaturesRepository,
|
||||||
|
qualifiedHostsService: QualifiedHostsService,
|
||||||
|
checkBookingLimitsService: CheckBookingLimitsService,
|
||||||
|
cacheService: CacheService,
|
||||||
|
userAvailabilityService: UserAvailabilityService,
|
||||||
|
busyTimesService: BusyTimesService,
|
||||||
|
noSlotsNotificationService: NoSlotsNotificationService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
oooRepo: oooRepoDependency,
|
oooRepo: oooRepoDependency,
|
||||||
@@ -28,9 +45,17 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
teamRepo: teamRepository,
|
teamRepo: teamRepository,
|
||||||
routingFormResponseRepo: routingFormResponseRepository,
|
routingFormResponseRepo: routingFormResponseRepository,
|
||||||
bookingRepo: bookingRepository,
|
bookingRepo: bookingRepository,
|
||||||
selectedSlotsRepo: selectedSlotsRepository,
|
selectedSlotRepo: selectedSlotRepository,
|
||||||
eventTypeRepo: eventTypeRepository,
|
eventTypeRepo: eventTypeRepository,
|
||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
|
redisClient: redisService,
|
||||||
|
checkBookingLimitsService,
|
||||||
|
cacheService,
|
||||||
|
userAvailabilityService,
|
||||||
|
busyTimesService,
|
||||||
|
qualifiedHostsService,
|
||||||
|
featuresRepo: featuresRepository,
|
||||||
|
noSlotsNotificationService,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BusyTimesService extends BaseBusyTimesService {
|
||||||
|
constructor(bookingRepository: PrismaBookingRepository) {
|
||||||
|
super({
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CacheService extends BaseCacheService {
|
||||||
|
constructor(featuresRepository: PrismaFeaturesRepository) {
|
||||||
|
super({ featuresRepository });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CheckBookingAndDurationLimitsService as BaseCheckBookingAndDurationLimitsService } from "@calcom/platform-libraries/bookings";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CheckBookingAndDurationLimitsService extends BaseCheckBookingAndDurationLimitsService {
|
||||||
|
constructor(checkBookingLimitsService: CheckBookingLimitsService) {
|
||||||
|
super({
|
||||||
|
checkBookingLimitsService: checkBookingLimitsService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CheckBookingLimitsService as BaseCheckBookingLimitsService } from "@calcom/platform-libraries/bookings";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CheckBookingLimitsService extends BaseCheckBookingLimitsService {
|
||||||
|
constructor(bookingRepository: PrismaBookingRepository) {
|
||||||
|
super({
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { FilterHostsService as BaseFilterHostsService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilterHostsService extends BaseFilterHostsService {
|
||||||
|
constructor(bookingRepository: PrismaBookingRepository) {
|
||||||
|
super({
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
|
||||||
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { NoSlotsNotificationService as BaseNoSlotsNotificationService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NoSlotsNotificationService extends BaseNoSlotsNotificationService {
|
||||||
|
constructor(
|
||||||
|
teamRepository: PrismaTeamRepository,
|
||||||
|
membershipRepository: PrismaMembershipRepository,
|
||||||
|
redisService: RedisService
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
teamRepo: teamRepository,
|
||||||
|
membershipRepo: membershipRepository,
|
||||||
|
redisClient: redisService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { QualifiedHostsService as BaseQualifiedHostsService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QualifiedHostsService extends BaseQualifiedHostsService {
|
||||||
|
constructor(bookingRepository: PrismaBookingRepository, filterHostsService: FilterHostsService) {
|
||||||
|
super({
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
filterHostsService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||||
|
constructor(
|
||||||
|
oooRepoDependency: PrismaOOORepository,
|
||||||
|
bookingRepository: PrismaBookingRepository,
|
||||||
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
|
redisService: RedisService
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
oooRepo: oooRepoDependency,
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
eventTypeRepo: eventTypeRepository,
|
||||||
|
redisClient: redisService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getEnv } from "@/env";
|
import { getEnv } from "@/env";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ThrottlerException,
|
ThrottlerException,
|
||||||
ThrottlerRequest,
|
ThrottlerRequest,
|
||||||
ThrottlerModuleOptions,
|
ThrottlerModuleOptions,
|
||||||
seconds,
|
|
||||||
} from "@nestjs/throttler";
|
} from "@nestjs/throttler";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
if (authorizationHeader) {
|
if (authorizationHeader) {
|
||||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||||
return isApiKey(authorizationHeader, apiKeyPrefix)
|
return isApiKey(authorizationHeader, apiKeyPrefix)
|
||||||
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||||
: `access_token_${authorizationHeader}`;
|
: `access_token_${sha256Hash(authorizationHeader)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||||
|
|
||||||
if (oauthClientId) {
|
if (oauthClientId) {
|
||||||
return `oauth_client_${oauthClientId}`;
|
return `oauth_client_${sha256Hash(oauthClientId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IP) {
|
if (IP) {
|
||||||
return `ip_${IP}`;
|
return `ip_${sha256Hash(IP.toString())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(`no tracker found: ${request.url}`);
|
this.logger.verbose(`no tracker found: ${request.url}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
|
import { sha256Hash, stripApiKey } from "@/lib/api-key";
|
||||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
|
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
|
||||||
@@ -61,7 +61,7 @@ export class ApiKeysService {
|
|||||||
|
|
||||||
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
|
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
|
||||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||||
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
|
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
if (!apiKeyInDb) {
|
if (!apiKeyInDb) {
|
||||||
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
|
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
|
|||||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||||
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
|
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
|
||||||
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
|
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
|
||||||
|
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
|
||||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||||
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
|
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
|
||||||
|
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisService } from "@/modules/redis/redis.service";
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
@@ -21,6 +24,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
|
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
EventTypesAtomService,
|
EventTypesAtomService,
|
||||||
ConferencingAtomsService,
|
ConferencingAtomsService,
|
||||||
AttributesAtomsService,
|
AttributesAtomsService,
|
||||||
@@ -30,6 +34,7 @@ import { Module } from "@nestjs/common";
|
|||||||
AtomsRepository,
|
AtomsRepository,
|
||||||
UsersService,
|
UsersService,
|
||||||
SchedulesAtomsService,
|
SchedulesAtomsService,
|
||||||
|
VerificationAtomsService,
|
||||||
RedisService,
|
RedisService,
|
||||||
],
|
],
|
||||||
exports: [EventTypesAtomService],
|
exports: [EventTypesAtomService],
|
||||||
@@ -38,6 +43,7 @@ import { Module } from "@nestjs/common";
|
|||||||
AtomsEventTypesController,
|
AtomsEventTypesController,
|
||||||
AtomsConferencingAppsController,
|
AtomsConferencingAppsController,
|
||||||
AtomsSchedulesController,
|
AtomsSchedulesController,
|
||||||
|
AtomsVerificationController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AtomsModule {}
|
export class AtomsModule {}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
|
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||||
|
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||||
|
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||||
|
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
|
||||||
|
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
|
||||||
|
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Version,
|
||||||
|
VERSION_NEUTRAL,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/atoms",
|
||||||
|
version: API_VERSIONS_VALUES,
|
||||||
|
})
|
||||||
|
@DocsTags("Atoms - verification endpoints for atoms")
|
||||||
|
@DocsExcludeController(true)
|
||||||
|
export class AtomsVerificationController {
|
||||||
|
constructor(private readonly verificationService: VerificationAtomsService) {}
|
||||||
|
|
||||||
|
@Post("/verification/email/send-code")
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
|
||||||
|
async sendEmailVerificationCode(
|
||||||
|
@Body() body: SendVerificationEmailInput
|
||||||
|
): Promise<SendVerificationEmailOutput> {
|
||||||
|
const result = await this.verificationService.sendEmailVerificationCode({
|
||||||
|
email: body.email,
|
||||||
|
username: body.username,
|
||||||
|
language: body.language,
|
||||||
|
isVerifyingEmail: body.isVerifyingEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: { sent: result.ok && !result.skipped },
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/verification/email/check")
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async checkEmailVerificationRequired(
|
||||||
|
@Query() query: CheckEmailVerificationRequiredParams
|
||||||
|
): Promise<ApiResponse<boolean>> {
|
||||||
|
const required = await this.verificationService.checkEmailVerificationRequired({
|
||||||
|
email: query.email,
|
||||||
|
userSessionEmail: query.userSessionEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: required,
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/verification/email/verify-code")
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
|
||||||
|
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
|
||||||
|
email: body.email,
|
||||||
|
code: body.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: { verified },
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/verification/email/verify-code-authenticated")
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyEmailCodeAuthenticated(
|
||||||
|
@Body() body: VerifyEmailCodeInput,
|
||||||
|
@GetUser() user: UserWithProfile
|
||||||
|
): Promise<VerifyEmailCodeOutput> {
|
||||||
|
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
|
||||||
|
email: body.email,
|
||||||
|
code: body.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: { verified },
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class CheckEmailVerificationRequiredParams {
|
||||||
|
@ApiProperty({ example: "user@example.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "user@example.com" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userSessionEmail?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
export class SendVerificationEmailInput {
|
||||||
|
@ApiProperty({ example: "user@example.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "johndoe" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: "en" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isVerifyingEmail?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class VerifyEmailCodeInput {
|
||||||
|
@ApiProperty({ example: "user@example.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "123456" })
|
||||||
|
@IsString()
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import { IsString, ValidateNested, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class SendVerificationEmailData {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
@IsBoolean()
|
||||||
|
@Expose()
|
||||||
|
readonly sent!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SendVerificationEmailOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => SendVerificationEmailData)
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty({ type: SendVerificationEmailData })
|
||||||
|
readonly data!: SendVerificationEmailData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import { IsString, ValidateNested, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class VerifyEmailCodeData {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
@IsBoolean()
|
||||||
|
@Expose()
|
||||||
|
readonly verified!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VerifyEmailCodeOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => VerifyEmailCodeData)
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty({ type: VerifyEmailCodeData })
|
||||||
|
readonly data!: VerifyEmailCodeData;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/
|
|||||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
||||||
@@ -53,7 +54,8 @@ export class EventTypesAtomService {
|
|||||||
private readonly dbWrite: PrismaWriteService,
|
private readonly dbWrite: PrismaWriteService,
|
||||||
private readonly dbRead: PrismaReadService,
|
private readonly dbRead: PrismaReadService,
|
||||||
private readonly eventTypeService: EventTypesService_2024_06_14,
|
private readonly eventTypeService: EventTypesService_2024_06_14,
|
||||||
private readonly teamEventTypeService: TeamsEventTypesService
|
private readonly teamEventTypeService: TeamsEventTypesService,
|
||||||
|
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async getTeamSlug(teamId: number): Promise<string> {
|
private async getTeamSlug(teamId: number): Promise<string> {
|
||||||
@@ -88,10 +90,12 @@ export class EventTypesAtomService {
|
|||||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType?.team?.id) {
|
if (!isUserOrganizationAdmin) {
|
||||||
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
if (eventType?.team?.id) {
|
||||||
} else {
|
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
||||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
} else {
|
||||||
|
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||||
@@ -115,7 +119,8 @@ export class EventTypesAtomService {
|
|||||||
user: UserWithProfile,
|
user: UserWithProfile,
|
||||||
teamId: number
|
teamId: number
|
||||||
) {
|
) {
|
||||||
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
|
await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId);
|
||||||
|
|
||||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||||
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
|
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
|
||||||
|
|
||||||
@@ -175,14 +180,31 @@ export class EventTypesAtomService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkCanUpdateTeamEventType(
|
async checkCanUpdateTeamEventType(
|
||||||
userId: number,
|
user: UserWithProfile,
|
||||||
eventTypeId: number,
|
eventTypeId: number,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
scheduleId: number | null | undefined
|
scheduleId: number | null | undefined
|
||||||
) {
|
) {
|
||||||
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
|
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||||
|
|
||||||
|
if (organizationId) {
|
||||||
|
const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin(
|
||||||
|
user.id,
|
||||||
|
organizationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUserOrganizationAdmin) {
|
||||||
|
const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId);
|
||||||
|
if (orgTeam) {
|
||||||
|
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId);
|
||||||
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
||||||
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
|
await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
|
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||||
|
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||||
|
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||||
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
verifyCodeUnAuthenticated,
|
||||||
|
verifyCodeAuthenticated,
|
||||||
|
sendEmailVerificationByCode,
|
||||||
|
checkEmailVerificationRequired,
|
||||||
|
} from "@calcom/platform-libraries";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VerificationAtomsService {
|
||||||
|
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
||||||
|
return await checkEmailVerificationRequired(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
|
||||||
|
try {
|
||||||
|
return await verifyCodeUnAuthenticated(input);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "invalid_code") {
|
||||||
|
throw new BadRequestException("Invalid verification code");
|
||||||
|
}
|
||||||
|
if (error.message === "BAD_REQUEST") {
|
||||||
|
throw new BadRequestException("Email and code are required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BadRequestException("Verification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
|
||||||
|
try {
|
||||||
|
return await verifyCodeAuthenticated({
|
||||||
|
user,
|
||||||
|
email: input.email,
|
||||||
|
code: input.code,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "invalid_code") {
|
||||||
|
throw new BadRequestException("Invalid verification code");
|
||||||
|
}
|
||||||
|
if (error.message === "BAD_REQUEST") {
|
||||||
|
throw new BadRequestException("Email, code, and user ID are required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException("Verification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
|
||||||
|
return await sendEmailVerificationByCode({
|
||||||
|
email: input.email,
|
||||||
|
username: input.username,
|
||||||
|
language: input.language,
|
||||||
|
isVerifyingEmail: input.isVerifyingEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
|||||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
ProfilesModule,
|
|
||||||
],
|
],
|
||||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
exports: [NextAuthGuard, ApiAuthGuard],
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
|||||||
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
|
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
|
||||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||||
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import { createRequest } from "node-mocks-http";
|
import { createRequest } from "node-mocks-http";
|
||||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
|
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
|
||||||
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
|
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
|
||||||
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
}),
|
}),
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
|
MembershipsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MockedRedisService,
|
MockedRedisService,
|
||||||
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||||
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
|
||||||
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
|
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
|
||||||
organizationTwo = await teamRepositoryFixture.create({
|
organizationTwo = await teamRepositoryFixture.create({
|
||||||
name: `api-auth-organization-2-${randomString()}`,
|
name: `api-auth-organization-2-${randomString()}`,
|
||||||
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
|
|||||||
organization: { connect: { id: organizationTwo.id } },
|
organization: { connect: { id: organizationTwo.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: validOAuthUser.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: validOAuthUser.id } },
|
||||||
|
team: { connect: { id: organizationTwo.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
name: "name",
|
name: "name",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||||
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||||
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly apiKeyRepository: ApiKeysRepository,
|
private readonly apiKeyRepository: ApiKeysRepository,
|
||||||
private readonly oauthRepository: OAuthClientRepository,
|
private readonly oauthRepository: OAuthClientRepository,
|
||||||
private readonly profilesRepository: ProfilesRepository,
|
private readonly usersService: UsersService,
|
||||||
private readonly usersService: UsersService
|
private readonly membershipsRepository: MembershipsRepository
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
const platformCreatorId =
|
||||||
|
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
|
||||||
|
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
|
||||||
|
|
||||||
if (!platformCreatorId) {
|
if (!platformCreatorId) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
@@ -226,7 +228,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
if (!keyData) {
|
if (!keyData) {
|
||||||
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
||||||
|
|||||||
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
fromReschedule?: string | null;
|
fromReschedule?: string | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const { uid, startTime, fromReschedule } = booking;
|
const { uid, startTime, fromReschedule } = booking;
|
||||||
|
|
||||||
const delay = startTime.getTime() - Date.now();
|
const delay = startTime.getTime() - Date.now();
|
||||||
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||||
*/
|
*/
|
||||||
async cancelUsageByBookingUid(bookingUid: string) {
|
async cancelUsageByBookingUid(bookingUid: string) {
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||||
if (job) {
|
if (job) {
|
||||||
await job.remove();
|
await job.remove();
|
||||||
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await this.billingQueue.close();
|
await this.billingQueue.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
|
|||||||
+52
-2
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
|||||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
|
||||||
|
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||||
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||||
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
BadRequestException,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
|
|||||||
type: String,
|
type: String,
|
||||||
})
|
})
|
||||||
@Get("/:calendar/event/:eventUid")
|
@Get("/:calendar/event/:eventUid")
|
||||||
|
@Get("/:calendar/events/:eventUid")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
|
|||||||
data: transformedEvent,
|
data: transformedEvent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiParam({
|
||||||
|
name: "calendar",
|
||||||
|
enum: [GOOGLE_CALENDAR],
|
||||||
|
type: String,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "eventUid",
|
||||||
|
description:
|
||||||
|
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||||
|
type: String,
|
||||||
|
})
|
||||||
|
@Patch("/:calendar/events/:eventUid")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Update meeting details in calendar",
|
||||||
|
description: "Updates event information in the specified calendar provider",
|
||||||
|
})
|
||||||
|
async updateCalendarEvent(
|
||||||
|
@Param("calendar") calendar: string,
|
||||||
|
@Param("eventUid") eventUid: string,
|
||||||
|
@Body() updateData: UpdateUnifiedCalendarEventInput
|
||||||
|
): Promise<GetUnifiedCalendarEventOutput> {
|
||||||
|
if (calendar !== GOOGLE_CALENDAR) {
|
||||||
|
throw new BadRequestException("Event updates are currently only available for Google Calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
|
||||||
|
|
||||||
|
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: transformedEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user