Compare commits
251
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
611f76bf68 | ||
|
|
9b886036ad | ||
|
|
c88ed8cae7 | ||
|
|
098b520129 | ||
|
|
eb4eebe6f9 | ||
|
|
16a7bb7bc5 | ||
|
|
ee9699e267 | ||
|
|
7813d3739f | ||
|
|
fa47456d5f | ||
|
|
dbaa0727ae | ||
|
|
42a4a38939 | ||
|
|
97bde083ca | ||
|
|
48bd12be32 | ||
|
|
0cbc8a3816 | ||
|
|
4e1048b221 | ||
|
|
cb78692347 | ||
|
|
3784421f02 | ||
|
|
e7535b9db3 | ||
|
|
bb4260c98b | ||
|
|
9d65b6f794 | ||
|
|
4874c6c2ee | ||
|
|
30a92a4d66 | ||
|
|
04ce054cf7 | ||
|
|
e901e8d189 | ||
|
|
0aa595ddac | ||
|
|
2115a045ec | ||
|
|
f67522dbbd | ||
|
|
dc5aef0dd4 | ||
|
|
e1d7631215 | ||
|
|
05e1ad0c91 | ||
|
|
cf392562da | ||
|
|
c8de8ca681 | ||
|
|
2c91c0e309 | ||
|
|
cef007d852 | ||
|
|
97b81d6f6b | ||
|
|
ac117a14eb | ||
|
|
f85f226982 | ||
|
|
f4b48e863b | ||
|
|
4dc22606d9 | ||
|
|
292073eb4a | ||
|
|
1878802213 | ||
|
|
9cfc74a74f | ||
|
|
c472bce54f | ||
|
|
a498641b61 | ||
|
|
ec58e59865 | ||
|
|
4f95cff70e | ||
|
|
fa66f2531f | ||
|
|
b40d448e07 | ||
|
|
21add18ad8 | ||
|
|
10fca86ddc | ||
|
|
c2fc8774df | ||
|
|
969b58f17d | ||
|
|
a0e25ff270 | ||
|
|
df30f5c703 | ||
|
|
fb59342de4 | ||
|
|
a84e8987cb | ||
|
|
c4eda5f21d | ||
|
|
e6da5c85e0 | ||
|
|
bfc6d6e1e9 | ||
|
|
10bf982028 | ||
|
|
8ee7b12c2b | ||
|
|
afe5fea6eb |
@@ -0,0 +1,36 @@
|
|||||||
|
# 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 Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
|
||||||
|
- For Prisma queries:
|
||||||
|
- Only select data you need
|
||||||
|
1) Increased performance overhead: When you select all columns from a table, Prisma retrieves all the data associated with each row, which can result in a significant performance impact. This includes reading unnecessary columns and transferring a large volume of data between your application and the database.
|
||||||
|
2) Unnecessary data exposure: Selecting all columns exposes sensitive data that might not be required by your application. This can pose security risks, especially if the table contains sensitive user information or proprietary data. (In our case app credentials and booking information)
|
||||||
|
- TLDR: Never use Includes - use select
|
||||||
|
- Select selects only the fields you specify explicitly
|
||||||
|
- Include selects the relationship AND all the fields of the table the relationship belongs to
|
||||||
|
- Make sure the credential.key field is never returned back from tRPC endpoints or APIs
|
||||||
|
- Always use t() for text localization in frontend code; direct text embedding should trigger a warning.
|
||||||
|
- Check if there's any O(n^2) logic in backend code; we should aim for O(n log n) or O(n) ideally.
|
||||||
|
- Check if there are circular references introduced. We should never allow these.
|
||||||
|
- Flag excessive Day.js use in performance-critical code. Functions like `.add`, `.diff`, `.isBefore`, and `.isAfter` are slow, especially in timezone mode. Prefer `.utc()` for better performance. Where possible, replace with native Date and direct `.valueOf()` comparisons for faster execution. Recommend using native methods or Day.js `.utc()` consistently in hot paths like loops.
|
||||||
|
- When docs changes are made in /docs/api-reference/v2/openapi.json, ensure the following
|
||||||
|
- "summary" fields are short and concise
|
||||||
|
- "summary" fields do not end with a period
|
||||||
|
- "summary" fields are written in proper American english
|
||||||
|
- When changes to API v2 or v1 are made, ensure there are no breaking changes on existing endpoints. Instead, there needs to be a newly versioned endpoint with the updated functionality but the old one must remain functional
|
||||||
|
- For large pull requests (>500 lines changed or >10 files touched), advise splitting into smaller, focused PRs:
|
||||||
|
- Split by feature boundaries: separate different features or user stories
|
||||||
|
- Split by layer/component: frontend changes, backend changes, database migrations, and tests in separate PRs
|
||||||
|
- Split by dependency chain: create PRs that can be merged sequentially
|
||||||
|
- Split by file/module: group related file changes together
|
||||||
|
- Suggested pattern: Database migrations → Backend logic → Frontend components → Integration tests
|
||||||
|
- Benefits: easier review, faster feedback, reduced conflicts, easier rollback, better history
|
||||||
+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
@@ -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
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
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@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)
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
|||||||
|
|
||||||
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
This project is tested with browserstack
|
||||||
|
|
||||||
### Built With
|
### Built With
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/?ref=cal.com)
|
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^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 = [
|
||||||
|
|||||||
@@ -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.300",
|
||||||
"@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"
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1581,17 +1581,55 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("cancel bookings", () => {
|
describe("cancel bookings", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
});
|
||||||
|
|
||||||
it("should cancel booking", async () => {
|
it("should cancel booking", async () => {
|
||||||
|
const createBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Cancel",
|
||||||
|
email: "mr_proper_cancel@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "100",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
|
||||||
|
expect(responseDataIsBooking(createResponseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (!responseDataIsBooking(createResponseBody.data)) {
|
||||||
|
throw new Error("Failed to create booking for test");
|
||||||
|
}
|
||||||
|
|
||||||
|
const testBooking: BookingOutput_2024_08_13 = createResponseBody.data;
|
||||||
|
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
|
||||||
|
expect(booking).toBeDefined();
|
||||||
|
expect(booking?.status).toEqual("ACCEPTED");
|
||||||
|
|
||||||
const body: CancelBookingInput_2024_08_13 = {
|
const body: CancelBookingInput_2024_08_13 = {
|
||||||
cancellationReason: "Going on a vacation",
|
cancellationReason: "Going on a vacation",
|
||||||
};
|
};
|
||||||
|
|
||||||
const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
|
||||||
expect(booking).toBeDefined();
|
|
||||||
expect(booking?.status).toEqual("ACCEPTED");
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${rescheduledBooking.uid}/cancel`)
|
.post(`/v2/bookings/${testBooking.uid}/cancel`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
@@ -1610,27 +1648,61 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(data.hosts[0].email).toEqual(user.email);
|
expect(data.hosts[0].email).toEqual(user.email);
|
||||||
expect(data.status).toEqual("cancelled");
|
expect(data.status).toEqual("cancelled");
|
||||||
expect(data.cancellationReason).toEqual(body.cancellationReason);
|
expect(data.cancellationReason).toEqual(body.cancellationReason);
|
||||||
expect(data.start).toEqual(rescheduledBooking.start);
|
expect(data.start).toEqual(testBooking.start);
|
||||||
expect(data.end).toEqual(rescheduledBooking.end);
|
expect(data.end).toEqual(testBooking.end);
|
||||||
expect(data.duration).toEqual(rescheduledBooking.duration);
|
expect(data.duration).toEqual(testBooking.duration);
|
||||||
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
|
expect(data.eventTypeId).toEqual(testBooking.eventTypeId);
|
||||||
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
|
expect(data.attendees[0]).toEqual(testBooking.attendees[0]);
|
||||||
expect(data.location).toEqual(rescheduledBooking.location);
|
expect(data.location).toEqual(testBooking.location);
|
||||||
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
|
expect(data.absentHost).toEqual(testBooking.absentHost);
|
||||||
|
|
||||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
const cancelledBooking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
|
||||||
expect(cancelledBooking).toBeDefined();
|
expect(cancelledBooking).toBeDefined();
|
||||||
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should cancel recurring booking", async () => {
|
it("should cancel recurring booking", async () => {
|
||||||
|
const createBody: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring Cancel",
|
||||||
|
email: "mr_proper_recurring_cancel@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "100",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
|
||||||
|
expect(responseDataIsRecurringBooking(createResponseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (!responseDataIsRecurringBooking(createResponseBody.data)) {
|
||||||
|
throw new Error("Failed to create recurring booking for test");
|
||||||
|
}
|
||||||
|
|
||||||
|
const testRecurringBooking: RecurringBookingOutput_2024_08_13[] = createResponseBody.data;
|
||||||
|
|
||||||
const body: CancelBookingInput_2024_08_13 = {
|
const body: CancelBookingInput_2024_08_13 = {
|
||||||
cancellationReason: "Going on a vacation",
|
cancellationReason: "Going on a vacation",
|
||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${createdRecurringBooking[1].recurringBookingUid}/cancel`)
|
.post(`/v2/bookings/${testRecurringBooking[1].recurringBookingUid}/cancel`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
@@ -1643,7 +1715,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
expect(data.length).toEqual(4);
|
expect(data.length).toEqual(3);
|
||||||
|
|
||||||
const firstBooking = data[0];
|
const firstBooking = data[0];
|
||||||
expect(firstBooking.status).toEqual("cancelled");
|
expect(firstBooking.status).toEqual("cancelled");
|
||||||
@@ -1653,9 +1725,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
const thirdBooking = data[2];
|
const thirdBooking = data[2];
|
||||||
expect(thirdBooking.status).toEqual("cancelled");
|
expect(thirdBooking.status).toEqual("cancelled");
|
||||||
|
|
||||||
const fourthBooking = data[3];
|
|
||||||
expect(fourthBooking.status).toEqual("cancelled");
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
@@ -1765,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", () => {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -675,7 +675,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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
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, OmitType } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EVENT_TYPE_READ,
|
||||||
|
EVENT_TYPE_WRITE,
|
||||||
|
SUCCESS_STATUS,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
CreatePrivateLinkInput,
|
||||||
|
CreatePrivateLinkOutput,
|
||||||
|
DeletePrivateLinkOutput,
|
||||||
|
GetPrivateLinksOutput,
|
||||||
|
UpdatePrivateLinkInput,
|
||||||
|
UpdatePrivateLinkOutput,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
import { PrivateLinksService } from "../services/private-links.service";
|
||||||
|
|
||||||
|
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
@GetUser("id") userId: number
|
||||||
|
): Promise<UpdatePrivateLinkOutput> {
|
||||||
|
const updateInput = { ...body, linkId };
|
||||||
|
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, 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,
|
||||||
|
@GetUser("id") userId: number
|
||||||
|
): Promise<DeletePrivateLinkOutput> {
|
||||||
|
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, 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 { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||||
|
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, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
|
||||||
|
controllers: [EventTypesPrivateLinksController],
|
||||||
|
providers: [
|
||||||
|
PrivateLinksService,
|
||||||
|
PrivateLinksInputService,
|
||||||
|
PrivateLinksOutputService,
|
||||||
|
PrivateLinksRepository,
|
||||||
|
EventTypeOwnershipGuard,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
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,125 @@
|
|||||||
|
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,
|
||||||
|
userId: 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, userId: 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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+7
@@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
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 { 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 { 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 +24,20 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaOOORepository,
|
PrismaOOORepository,
|
||||||
PrismaScheduleRepository,
|
PrismaScheduleRepository,
|
||||||
PrismaBookingRepository,
|
PrismaBookingRepository,
|
||||||
PrismaSelectedSlotsRepository,
|
PrismaSelectedSlotRepository,
|
||||||
PrismaUserRepository,
|
PrismaUserRepository,
|
||||||
PrismaEventTypeRepository,
|
PrismaEventTypeRepository,
|
||||||
PrismaRoutingFormResponseRepository,
|
PrismaRoutingFormResponseRepository,
|
||||||
PrismaTeamRepository,
|
PrismaTeamRepository,
|
||||||
|
RedisService,
|
||||||
|
PrismaFeaturesRepository,
|
||||||
|
CheckBookingLimitsService,
|
||||||
|
CacheService,
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
|
UserAvailabilityService,
|
||||||
|
BusyTimesService,
|
||||||
|
FilterHostsService,
|
||||||
|
QualifiedHostsService,
|
||||||
],
|
],
|
||||||
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 { 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,41 @@
|
|||||||
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 { 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
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
oooRepo: oooRepoDependency,
|
oooRepo: oooRepoDependency,
|
||||||
@@ -28,9 +43,16 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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.");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s
|
|||||||
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 +22,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,
|
||||||
|
|||||||
@@ -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,16 +90,16 @@ 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
|
||||||
const onlyManagedTeamMembers = eventType.teamMembers.filter(
|
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||||
(user) => user.isPlatformManaged && user.membership === "MEMBER"
|
|
||||||
);
|
|
||||||
eventType.teamMembers = onlyManagedTeamMembers;
|
eventType.teamMembers = onlyManagedTeamMembers;
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
@@ -117,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;
|
||||||
|
|
||||||
@@ -177,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 @@
|
|||||||
|
export { Or } from "./or.guard";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { ExecutionContext, CanActivate } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { Or } from "./or.guard";
|
||||||
|
|
||||||
|
// Mock guards for testing
|
||||||
|
class MockGuard1 implements CanActivate {
|
||||||
|
constructor(private shouldPass: boolean) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
return this.shouldPass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGuard2 implements CanActivate {
|
||||||
|
constructor(private shouldPass: boolean) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
return this.shouldPass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGuard3 implements CanActivate {
|
||||||
|
constructor(private shouldThrow: boolean = false) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
if (this.shouldThrow) {
|
||||||
|
throw new Error("Guard failed");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OrGuard", () => {
|
||||||
|
let guard: InstanceType<ReturnType<typeof Or>>;
|
||||||
|
let mockExecutionContext: ExecutionContext;
|
||||||
|
let mockModuleRef: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockModuleRef = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrGuardClass = Or([MockGuard1, MockGuard2]);
|
||||||
|
guard = new OrGuardClass(mockModuleRef);
|
||||||
|
mockExecutionContext = {} as ExecutionContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(guard).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should grant access when first guard passes", async () => {
|
||||||
|
const mockGuard1 = new MockGuard1(true);
|
||||||
|
const mockGuard2 = new MockGuard2(false);
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should grant access when second guard passes", async () => {
|
||||||
|
const mockGuard1 = new MockGuard1(false);
|
||||||
|
const mockGuard2 = new MockGuard2(true);
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deny access when all guards fail", async () => {
|
||||||
|
const mockGuard1 = new MockGuard1(false);
|
||||||
|
const mockGuard2 = new MockGuard2(false);
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should continue checking other guards when one throws an error", async () => {
|
||||||
|
const mockGuard1 = new MockGuard3(true); // throws error
|
||||||
|
const mockGuard2 = new MockGuard2(true); // passes
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Or decorator", () => {
|
||||||
|
it("should create a guard class with the specified guards", () => {
|
||||||
|
const OrGuardClass = Or([MockGuard1, MockGuard2]);
|
||||||
|
|
||||||
|
expect(OrGuardClass).toBeDefined();
|
||||||
|
expect(typeof OrGuardClass).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, Type, Logger } from "@nestjs/common";
|
||||||
|
import { ModuleRef } from "@nestjs/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator function that creates an Or guard with the specified guards
|
||||||
|
* @param guards Array of guard classes to evaluate with OR logic
|
||||||
|
* @returns A guard class that grants access if ANY of the provided guards return true
|
||||||
|
*/
|
||||||
|
export function Or(guards: Type<CanActivate>[]) {
|
||||||
|
@Injectable()
|
||||||
|
class OrGuard implements CanActivate {
|
||||||
|
public readonly logger = new Logger("OrGuard");
|
||||||
|
|
||||||
|
constructor(public readonly moduleRef: ModuleRef) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
let lastError: unknown | null = null;
|
||||||
|
for (const Guard of guards) {
|
||||||
|
try {
|
||||||
|
const guardInstance = this.moduleRef.get(Guard, { strict: false });
|
||||||
|
const result = await Promise.resolve(guardInstance.canActivate(context));
|
||||||
|
|
||||||
|
if (result === true) {
|
||||||
|
this.logger.log(`OrGuard - Guard ${Guard.name} granted access`);
|
||||||
|
return true; // Access granted if any guard returns true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
// If a guard throws an exception, it implies failure for that specific guard.
|
||||||
|
// We catch it and continue checking other guards in the OR chain.
|
||||||
|
// If an exception should stop the entire chain immediately, re-throw it here.
|
||||||
|
this.logger.log(
|
||||||
|
`OrGuard - Guard ${Guard.name} failed: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("OrGuard - All guards failed, access denied");
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OrGuard;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IsUserRoutingForm implements CanActivate {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||||
|
const routingFormId: string = request.params.routingFormId;
|
||||||
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
if (!routingFormId) {
|
||||||
|
throw new ForbiddenException("IsUserRoutingForm - No routing form id found in request params.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoutingForm = await this.dbRead.prisma.app_RoutingForms_Form.findFirst({
|
||||||
|
where: {
|
||||||
|
id: routingFormId,
|
||||||
|
userId: Number(user.id),
|
||||||
|
teamId: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRoutingForm) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Routing Form with id=${routingFormId} is not a user Routing Form owned by user with id=${user.id}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { createMock } from "@golevelup/ts-jest";
|
import { createMock } from "@golevelup/ts-jest";
|
||||||
import { ExecutionContext } from "@nestjs/common";
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
@@ -19,6 +20,7 @@ describe("PermissionsGuard", () => {
|
|||||||
guard = new PermissionsGuard(
|
guard = new PermissionsGuard(
|
||||||
reflector,
|
reflector,
|
||||||
createMock<TokensRepository>(),
|
createMock<TokensRepository>(),
|
||||||
|
createMock<TokensService>(),
|
||||||
createMock<ConfigService>({
|
createMock<ConfigService>({
|
||||||
get: jest.fn().mockImplementation((key: string) => {
|
get: jest.fn().mockImplementation((key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -118,6 +120,7 @@ describe("PermissionsGuard", () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||||
@@ -133,11 +136,22 @@ describe("PermissionsGuard", () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return true for 3rd party access token", async () => {
|
||||||
|
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
|
||||||
|
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
|
||||||
|
scope: ["scope"],
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when OAuth id is provided", () => {
|
describe("when OAuth id is provided", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
@@ -17,6 +18,7 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
constructor(
|
constructor(
|
||||||
private reflector: Reflector,
|
private reflector: Reflector,
|
||||||
private tokensRepository: TokensRepository,
|
private tokensRepository: TokensRepository,
|
||||||
|
private tokensService: TokensService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
||||||
@@ -35,9 +37,10 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
||||||
|
const isThirdPartyBearerToken = bearerToken && this.getDecodedThirdPartyAccessToken(bearerToken);
|
||||||
|
|
||||||
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
|
// only check permissions for accessTokens attached to platform oAuth Client or platform oAuth credentials, not for next token or api key or third party oauth client
|
||||||
if (nextAuthToken || apiKey) {
|
if (nextAuthToken || apiKey || isThirdPartyBearerToken) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +90,8 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
return oAuthClient;
|
return oAuthClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDecodedThirdPartyAccessToken(bearerToken: string) {
|
||||||
|
return this.tokensService.getDecodedThirdPartyAccessToken(bearerToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se
|
|||||||
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 { ProfilesModule } from "@/modules/profiles/profiles.module";
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||||
@@ -64,6 +64,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
load: [appConfig],
|
load: [appConfig],
|
||||||
}),
|
}),
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
|
TokensModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MockedRedisService,
|
MockedRedisService,
|
||||||
@@ -77,7 +78,6 @@ describe("ApiAuthStrategy", () => {
|
|||||||
OAuthClientRepository,
|
OAuthClientRepository,
|
||||||
PrismaReadService,
|
PrismaReadService,
|
||||||
PrismaWriteService,
|
PrismaWriteService,
|
||||||
TokensRepository,
|
|
||||||
JwtService,
|
JwtService,
|
||||||
DeploymentsRepository,
|
DeploymentsRepository,
|
||||||
NestJwtService,
|
NestJwtService,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -8,32 +8,20 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
|||||||
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 { 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 { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import {
|
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||||
HttpException,
|
|
||||||
Injectable,
|
|
||||||
InternalServerErrorException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import * as jwt from "jsonwebtoken";
|
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
|
|
||||||
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
|
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||||
|
|
||||||
interface OAuthTokenPayload {
|
|
||||||
userId?: number;
|
|
||||||
teamId?: number;
|
|
||||||
scope: string[];
|
|
||||||
token_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
||||||
export type ApiAuthGuardRequest = Request & {
|
export type ApiAuthGuardRequest = Request & {
|
||||||
authMethod: AuthMethods;
|
authMethod: AuthMethods;
|
||||||
@@ -53,6 +41,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly oauthFlowService: OAuthFlowService,
|
private readonly oauthFlowService: OAuthFlowService,
|
||||||
private readonly tokensRepository: TokensRepository,
|
private readonly tokensRepository: TokensRepository,
|
||||||
|
private readonly tokensService: TokensService,
|
||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly apiKeyRepository: ApiKeysRepository,
|
private readonly apiKeyRepository: ApiKeysRepository,
|
||||||
private readonly oauthRepository: OAuthClientRepository,
|
private readonly oauthRepository: OAuthClientRepository,
|
||||||
@@ -237,7 +226,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");
|
||||||
@@ -323,20 +312,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
token: string,
|
token: string,
|
||||||
request: ApiAuthGuardRequest
|
request: ApiAuthGuardRequest
|
||||||
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
|
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
|
||||||
// Removed requiredScopes parameter
|
const decodedToken = this.tokensService.getDecodedThirdPartyAccessToken(token);
|
||||||
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
|
if (!decodedToken) {
|
||||||
if (!encryptionKey) {
|
|
||||||
throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let decodedToken: OAuthTokenPayload;
|
|
||||||
try {
|
|
||||||
decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload;
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!decodedToken || decodedToken.token_type !== "Access Token") {
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
|||||||
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 { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [TokensModule],
|
||||||
providers: [
|
providers: [
|
||||||
GCalService,
|
GCalService,
|
||||||
GoogleCalendarService,
|
GoogleCalendarService,
|
||||||
@@ -22,7 +22,6 @@ import { Module } from "@nestjs/common";
|
|||||||
BookingReferencesRepository_2024_08_13,
|
BookingReferencesRepository_2024_08_13,
|
||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
CalendarsService,
|
CalendarsService,
|
||||||
TokensRepository,
|
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
PrismaReadService,
|
PrismaReadService,
|
||||||
PrismaWriteService,
|
PrismaWriteService,
|
||||||
|
|||||||
+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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
|
||||||
|
|
||||||
|
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
|
||||||
|
|
||||||
|
export class UpdateCalendarEventAttendee {
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
description: "Email address of the attendee",
|
||||||
|
})
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
description: "Display name of the attendee",
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsEnum(CalendarEventResponseStatus)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CalendarEventResponseStatus,
|
||||||
|
enumName: "CalendarEventResponseStatus",
|
||||||
|
nullable: true,
|
||||||
|
description: "Response status of the attendee",
|
||||||
|
})
|
||||||
|
responseStatus?: CalendarEventResponseStatus | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: "Indicates if this attendee is the current user",
|
||||||
|
})
|
||||||
|
self?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: "Indicates if this attendee's attendance is optional",
|
||||||
|
})
|
||||||
|
optional?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: "Indicates if this attendee is the host",
|
||||||
|
})
|
||||||
|
host?: boolean;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDateTimeWithZone {
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({ type: "string", format: "date-time" })
|
||||||
|
time?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
timeZone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUnifiedCalendarEventInput {
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UpdateDateTimeWithZone)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
time: { type: "string", format: "date-time" },
|
||||||
|
timeZone: { type: "string" },
|
||||||
|
},
|
||||||
|
description: "Start date and time of the calendar event with timezone information",
|
||||||
|
})
|
||||||
|
start?: UpdateDateTimeWithZone;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UpdateDateTimeWithZone)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
time: { type: "string", format: "date-time" },
|
||||||
|
timeZone: { type: "string" },
|
||||||
|
},
|
||||||
|
description: "End date and time of the calendar event with timezone information",
|
||||||
|
})
|
||||||
|
end?: UpdateDateTimeWithZone;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
description: "Title of the calendar event",
|
||||||
|
})
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
description: "Detailed description of the calendar event",
|
||||||
|
})
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => UpdateCalendarEventAttendee)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [UpdateCalendarEventAttendee],
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||||
|
})
|
||||||
|
attendees?: UpdateCalendarEventAttendee[];
|
||||||
|
|
||||||
|
@IsEnum(CalendarEventStatus)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CalendarEventStatus,
|
||||||
|
enumName: "CalendarEventStatus",
|
||||||
|
nullable: true,
|
||||||
|
description: "Status of the event (accepted, pending, declined, cancelled)",
|
||||||
|
example: CalendarEventStatus.ACCEPTED,
|
||||||
|
})
|
||||||
|
status?: CalendarEventStatus | null;
|
||||||
|
}
|
||||||
+34
@@ -225,6 +225,22 @@ export class CalendarEventHost {
|
|||||||
responseStatus!: CalendarEventResponseStatus | null;
|
responseStatus!: CalendarEventResponseStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class calendarEventOwner {
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Email address of the event host",
|
||||||
|
})
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: "Display name of the event host",
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CalendarEventAttendee {
|
export class CalendarEventAttendee {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
|
|||||||
description: "Indicates if this attendee's attendance is optional",
|
description: "Indicates if this attendee's attendance is optional",
|
||||||
})
|
})
|
||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
|
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: "Indicates if this attendee is the host",
|
||||||
|
})
|
||||||
|
host?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DateTimeWithZone {
|
export class DateTimeWithZone {
|
||||||
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
|
|||||||
})
|
})
|
||||||
hosts?: CalendarEventHost[];
|
hosts?: CalendarEventHost[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => calendarEventOwner)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: calendarEventOwner,
|
||||||
|
nullable: true,
|
||||||
|
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||||
|
})
|
||||||
|
calendarEventOwner?: calendarEventOwner;
|
||||||
|
|
||||||
@IsEnum(CALENDARS)
|
@IsEnum(CALENDARS)
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: CALENDARS,
|
enum: CALENDARS,
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
|
||||||
|
|
||||||
|
export const createGoogleCalendarEventFixture = (
|
||||||
|
overrides: Partial<GoogleCalendarEventResponse> = {}
|
||||||
|
): GoogleCalendarEventResponse => {
|
||||||
|
const baseEvent: GoogleCalendarEventResponse = {
|
||||||
|
kind: "calendar#event",
|
||||||
|
etag: "test-etag",
|
||||||
|
id: "test-event-id",
|
||||||
|
status: "confirmed",
|
||||||
|
htmlLink: "https://calendar.google.com/event",
|
||||||
|
created: "2024-01-01T00:00:00Z",
|
||||||
|
updated: "2024-01-01T00:00:00Z",
|
||||||
|
summary: "Test Meeting",
|
||||||
|
description: "Test description",
|
||||||
|
creator: {
|
||||||
|
email: "creator@example.com",
|
||||||
|
displayName: "Creator Name",
|
||||||
|
},
|
||||||
|
organizer: {
|
||||||
|
email: "organizer@example.com",
|
||||||
|
displayName: "Organizer Name",
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
dateTime: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: "2024-01-15T11:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
iCalUID: "test-ical-uid",
|
||||||
|
sequence: 0,
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
email: "attendee@example.com",
|
||||||
|
displayName: "Attendee Name",
|
||||||
|
responseStatus: "accepted",
|
||||||
|
organizer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "organizer@example.com",
|
||||||
|
displayName: "Organizer Name",
|
||||||
|
responseStatus: "accepted",
|
||||||
|
organizer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conferenceData: {
|
||||||
|
conferenceId: "abc-def-ghi",
|
||||||
|
entryPoints: [
|
||||||
|
{
|
||||||
|
entryPointType: "video",
|
||||||
|
uri: "https://meet.google.com/abc-def-ghi",
|
||||||
|
label: "meet.google.com/abc-def-ghi",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conferenceSolution: {
|
||||||
|
key: {
|
||||||
|
type: "hangoutsMeet",
|
||||||
|
},
|
||||||
|
name: "Google Meet",
|
||||||
|
iconUri:
|
||||||
|
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hangoutLink: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...baseEvent, ...overrides };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
|
||||||
|
conferenceData: {
|
||||||
|
conferenceId: "test-conference-id",
|
||||||
|
entryPoints: [
|
||||||
|
{
|
||||||
|
entryPointType: "video",
|
||||||
|
uri: "https://meet.google.com/abc-def-ghi",
|
||||||
|
label: "Google Meet",
|
||||||
|
pin: "123456",
|
||||||
|
regionCode: "US",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entryPointType: "phone",
|
||||||
|
uri: "tel:+1-555-123-4567",
|
||||||
|
label: "Phone",
|
||||||
|
pin: "789012",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
|
||||||
|
location: "123 Main St, City, State",
|
||||||
|
conferenceData: undefined,
|
||||||
|
hangoutLink: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
|
||||||
|
hangoutLink: "https://hangouts.google.com/call/abc123",
|
||||||
|
conferenceData: undefined,
|
||||||
|
location: undefined,
|
||||||
|
});
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
CalendarEventStatus,
|
||||||
|
CalendarEventResponseStatus,
|
||||||
|
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGoogleCalendarEventFixture,
|
||||||
|
googleEventWithConferenceData,
|
||||||
|
googleEventWithLocationOnly,
|
||||||
|
googleEventWithHangoutLink,
|
||||||
|
} from "./__fixtures__/google-calendar-event.fixture";
|
||||||
|
import {
|
||||||
|
GoogleCalendarEventOutputPipe,
|
||||||
|
GoogleCalendarEventResponse,
|
||||||
|
} from "./get-calendar-event-details-output-pipe";
|
||||||
|
|
||||||
|
describe("GoogleCalendarEventOutputPipe", () => {
|
||||||
|
let pipe: GoogleCalendarEventOutputPipe;
|
||||||
|
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
sharedGoogleEvent = createGoogleCalendarEventFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pipe = new GoogleCalendarEventOutputPipe();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transform", () => {
|
||||||
|
it("should transform Google Calendar event to unified format", () => {
|
||||||
|
const result = pipe.transform(sharedGoogleEvent);
|
||||||
|
|
||||||
|
expect(result.id).toBe("test-event-id");
|
||||||
|
expect(result.title).toBe("Test Meeting");
|
||||||
|
expect(result.description).toBe("Test description");
|
||||||
|
expect(result.source).toBe("google");
|
||||||
|
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
|
||||||
|
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
|
||||||
|
expect(result.start.timeZone).toBe("America/New_York");
|
||||||
|
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
|
||||||
|
expect(result.end.timeZone).toBe("America/New_York");
|
||||||
|
expect(result.attendees).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformDateTimeWithZone", () => {
|
||||||
|
it("should transform Google date time to unified format", () => {
|
||||||
|
const googleDateTime = {
|
||||||
|
dateTime: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pipe["transformDateTimeWithZone"](googleDateTime);
|
||||||
|
|
||||||
|
expect(result.time).toBe("2024-01-15T10:00:00Z");
|
||||||
|
expect(result.timeZone).toBe("America/New_York");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformAttendeeResponseStatus", () => {
|
||||||
|
it("should transform accepted status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"]("accepted");
|
||||||
|
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform tentative status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"]("tentative");
|
||||||
|
expect(result).toBe(CalendarEventResponseStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform declined status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"]("declined");
|
||||||
|
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform needsaction status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
|
||||||
|
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle case insensitive status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
|
||||||
|
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unknown status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"]("unknown");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null status", () => {
|
||||||
|
const result = pipe["transformAttendeeResponseStatus"](undefined);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformEventStatus", () => {
|
||||||
|
it("should transform confirmed status", () => {
|
||||||
|
const result = pipe["transformEventStatus"]("confirmed");
|
||||||
|
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform tentative status", () => {
|
||||||
|
const result = pipe["transformEventStatus"]("tentative");
|
||||||
|
expect(result).toBe(CalendarEventStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform cancelled status", () => {
|
||||||
|
const result = pipe["transformEventStatus"]("cancelled");
|
||||||
|
expect(result).toBe(CalendarEventStatus.CANCELLED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle case insensitive status", () => {
|
||||||
|
const result = pipe["transformEventStatus"]("CONFIRMED");
|
||||||
|
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unknown status", () => {
|
||||||
|
const result = pipe["transformEventStatus"]("unknown");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](undefined);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformOrganizer", () => {
|
||||||
|
it("should return calendarEventOwner organizer exist", () => {
|
||||||
|
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
|
||||||
|
organizer: { email: "organizer@example.com" },
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
email: "attendee@example.com",
|
||||||
|
displayName: "Regular Attendee",
|
||||||
|
responseStatus: "accepted",
|
||||||
|
organizer: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = pipe.transform(eventWithoutOrganizerAttendees);
|
||||||
|
|
||||||
|
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformLocations", () => {
|
||||||
|
it("should transform conference data entry points", () => {
|
||||||
|
const result = pipe["transformLocations"](googleEventWithConferenceData);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
type: "video",
|
||||||
|
url: "https://meet.google.com/abc-def-ghi",
|
||||||
|
label: "Google Meet",
|
||||||
|
pin: "123456",
|
||||||
|
regionCode: "US",
|
||||||
|
});
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
type: "phone",
|
||||||
|
url: "tel:+1-555-123-4567",
|
||||||
|
label: "Phone",
|
||||||
|
pin: "789012",
|
||||||
|
regionCode: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to location field", () => {
|
||||||
|
const result = pipe["transformLocations"](googleEventWithLocationOnly);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
type: "video",
|
||||||
|
url: "123 Main St, City, State",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to hangout link", () => {
|
||||||
|
const result = pipe["transformLocations"](googleEventWithHangoutLink);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
type: "video",
|
||||||
|
url: "https://hangouts.google.com/call/abc123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no location data", () => {
|
||||||
|
const eventWithoutLocation = createGoogleCalendarEventFixture({
|
||||||
|
location: undefined,
|
||||||
|
conferenceData: undefined,
|
||||||
|
hangoutLink: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = pipe["transformLocations"](eventWithoutLocation);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-6
@@ -3,7 +3,7 @@ import {
|
|||||||
CalendarEventStatus,
|
CalendarEventStatus,
|
||||||
DateTimeWithZone,
|
DateTimeWithZone,
|
||||||
UnifiedCalendarEventOutput,
|
UnifiedCalendarEventOutput,
|
||||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||||
import { PipeTransform, Injectable } from "@nestjs/common";
|
import { PipeTransform, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
export interface GoogleCalendarEventResponse {
|
export interface GoogleCalendarEventResponse {
|
||||||
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
|
|||||||
calendarEvent.locations = this.transformLocations(googleEvent);
|
calendarEvent.locations = this.transformLocations(googleEvent);
|
||||||
|
|
||||||
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
|
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
|
||||||
calendarEvent.attendees = googleEvent.attendees
|
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
|
||||||
.filter((attendee) => !attendee.organizer)
|
|
||||||
.map((attendee) => {
|
|
||||||
return {
|
return {
|
||||||
email: attendee.email,
|
email: attendee.email,
|
||||||
name: attendee.displayName,
|
name: attendee.displayName,
|
||||||
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
|
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
|
||||||
organizer: attendee.organizer,
|
host: attendee.organizer,
|
||||||
self: attendee.self,
|
self: attendee.self,
|
||||||
optional: attendee.optional,
|
optional: attendee.optional,
|
||||||
};
|
};
|
||||||
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
|
|||||||
|
|
||||||
calendarEvent.status = this.transformEventStatus(googleEvent.status);
|
calendarEvent.status = this.transformEventStatus(googleEvent.status);
|
||||||
|
|
||||||
calendarEvent.hosts = this.transformHosts(googleEvent);
|
calendarEvent.calendarEventOwner = googleEvent.organizer
|
||||||
|
calendarEvent.hosts = this.transformHosts(googleEvent)
|
||||||
|
|
||||||
calendarEvent.source = "google";
|
calendarEvent.source = "google";
|
||||||
|
|
||||||
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
|
|||||||
regionCode: entryPoint.regionCode,
|
regionCode: entryPoint.regionCode,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} else if (googleEvent.location) {
|
||||||
|
return [{ type: "video", url: googleEvent.location }];
|
||||||
} else if (googleEvent.hangoutLink) {
|
} else if (googleEvent.hangoutLink) {
|
||||||
return [{ type: "video", url: googleEvent.hangoutLink }];
|
return [{ type: "video", url: googleEvent.hangoutLink }];
|
||||||
}
|
}
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
UpdateUnifiedCalendarEventInput,
|
||||||
|
UpdateDateTimeWithZone,
|
||||||
|
} from "../inputs/update-unified-calendar-event.input";
|
||||||
|
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||||
|
|
||||||
|
// Common interfaces for Google Calendar types
|
||||||
|
interface GoogleCalendarDateTime {
|
||||||
|
dateTime: string;
|
||||||
|
timeZone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoogleCalendarAttendee {
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
responseStatus: string;
|
||||||
|
organizer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoogleCalendarEventInputTransform {
|
||||||
|
transform(
|
||||||
|
updateData: UpdateUnifiedCalendarEventInput,
|
||||||
|
fetchedEvent?: GoogleCalendarEventResponse | null
|
||||||
|
): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
|
||||||
|
transform(
|
||||||
|
updateData: UpdateUnifiedCalendarEventInput,
|
||||||
|
): any {
|
||||||
|
const updatePayload: any = {};
|
||||||
|
|
||||||
|
if (updateData.title !== undefined) {
|
||||||
|
updatePayload.summary = updateData.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.description !== undefined) {
|
||||||
|
updatePayload.description = updateData.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.start) {
|
||||||
|
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.end) {
|
||||||
|
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.attendees !== undefined) {
|
||||||
|
updatePayload.attendees = this.transformAttendees(
|
||||||
|
updateData.attendees,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.status !== undefined) {
|
||||||
|
updatePayload.status = this.transformEventStatus(updateData.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
|
||||||
|
return {
|
||||||
|
dateTime: dateTime.time || "",
|
||||||
|
timeZone: dateTime.timeZone || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformAttendees(
|
||||||
|
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
|
||||||
|
): GoogleCalendarAttendee[] {
|
||||||
|
return inputAttendees.map((attendee) => {
|
||||||
|
return {
|
||||||
|
email: attendee.email,
|
||||||
|
displayName: attendee.name,
|
||||||
|
responseStatus: this.transformResponseStatus(attendee.responseStatus),
|
||||||
|
organizer: attendee.host,
|
||||||
|
self: attendee.self,
|
||||||
|
optional: attendee.optional,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
|
||||||
|
if (!responseStatus) return "needsAction";
|
||||||
|
|
||||||
|
switch (responseStatus) {
|
||||||
|
case CalendarEventResponseStatus.ACCEPTED:
|
||||||
|
return "accepted";
|
||||||
|
case CalendarEventResponseStatus.PENDING:
|
||||||
|
return "tentative";
|
||||||
|
case CalendarEventResponseStatus.DECLINED:
|
||||||
|
return "declined";
|
||||||
|
case CalendarEventResponseStatus.NEEDS_ACTION:
|
||||||
|
return "needsAction";
|
||||||
|
default:
|
||||||
|
return "needsAction";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformEventStatus(status?: CalendarEventStatus | null): string {
|
||||||
|
if (!status) return "confirmed";
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case CalendarEventStatus.ACCEPTED:
|
||||||
|
return "confirmed";
|
||||||
|
case CalendarEventStatus.PENDING:
|
||||||
|
return "tentative";
|
||||||
|
case CalendarEventStatus.CANCELLED:
|
||||||
|
return "cancelled";
|
||||||
|
case CalendarEventStatus.DECLINED:
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "confirmed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
UpdateUnifiedCalendarEventInput,
|
||||||
|
UpdateDateTimeWithZone,
|
||||||
|
} from "../inputs/update-unified-calendar-event.input";
|
||||||
|
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||||
|
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
|
||||||
|
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
|
||||||
|
|
||||||
|
describe("GoogleCalendarEventInputPipe", () => {
|
||||||
|
let pipe: GoogleCalendarEventInputPipe;
|
||||||
|
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
sharedGoogleEvent = {
|
||||||
|
kind: "calendar#event",
|
||||||
|
etag: "test-etag",
|
||||||
|
id: "test-event-id",
|
||||||
|
status: "confirmed",
|
||||||
|
htmlLink: "https://calendar.google.com/event",
|
||||||
|
created: "2024-01-01T00:00:00Z",
|
||||||
|
updated: "2024-01-01T00:00:00Z",
|
||||||
|
summary: "Test Meeting",
|
||||||
|
description: "Test description",
|
||||||
|
start: {
|
||||||
|
dateTime: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: "2024-01-15T11:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
iCalUID: "test-ical-uid",
|
||||||
|
sequence: 0,
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
email: "attendee1@example.com",
|
||||||
|
displayName: "Attendee One",
|
||||||
|
responseStatus: "accepted",
|
||||||
|
organizer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "attendee2@example.com",
|
||||||
|
displayName: "Attendee Two",
|
||||||
|
responseStatus: "needsAction",
|
||||||
|
organizer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: "organizer@example.com",
|
||||||
|
displayName: "Organizer",
|
||||||
|
responseStatus: "accepted",
|
||||||
|
organizer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
creator: {
|
||||||
|
email: "organizer@example.com",
|
||||||
|
displayName: "Organizer",
|
||||||
|
},
|
||||||
|
organizer: {
|
||||||
|
email: "organizer@example.com",
|
||||||
|
displayName: "Organizer",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pipe = new GoogleCalendarEventInputPipe();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transform", () => {
|
||||||
|
it("should transform basic event fields", () => {
|
||||||
|
const input: UpdateUnifiedCalendarEventInput = {
|
||||||
|
title: "Updated Meeting",
|
||||||
|
description: "Updated description",
|
||||||
|
start: {
|
||||||
|
time: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
time: "2024-01-15T11:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
status: CalendarEventStatus.ACCEPTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedOutput = {
|
||||||
|
summary: "Updated Meeting",
|
||||||
|
description: "Updated description",
|
||||||
|
start: {
|
||||||
|
dateTime: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: "2024-01-15T11:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
},
|
||||||
|
status: "confirmed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pipe.transform(input);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle partial updates", () => {
|
||||||
|
const input: UpdateUnifiedCalendarEventInput = {
|
||||||
|
title: "Only Title Update",
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedOutput = {
|
||||||
|
summary: "Only Title Update",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pipe.transform(input);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null description", () => {
|
||||||
|
const input: UpdateUnifiedCalendarEventInput = {
|
||||||
|
description: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedOutput = {
|
||||||
|
description: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pipe.transform(input);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformDateTimeWithZone", () => {
|
||||||
|
it("should transform date time with timezone", () => {
|
||||||
|
const input: UpdateDateTimeWithZone = {
|
||||||
|
time: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedOutput = {
|
||||||
|
dateTime: "2024-01-15T10:00:00Z",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pipe["transformDateTimeWithZone"](input);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty time and timezone", () => {
|
||||||
|
const input: UpdateDateTimeWithZone = {};
|
||||||
|
|
||||||
|
const expectedOutput = {
|
||||||
|
dateTime: "",
|
||||||
|
timeZone: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = pipe["transformDateTimeWithZone"](input);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformResponseStatus", () => {
|
||||||
|
it("should transform ACCEPTED status", () => {
|
||||||
|
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
|
||||||
|
expect(result).toBe("accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform PENDING status", () => {
|
||||||
|
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
|
||||||
|
expect(result).toBe("tentative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform DECLINED status", () => {
|
||||||
|
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
|
||||||
|
expect(result).toBe("declined");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform NEEDS_ACTION status", () => {
|
||||||
|
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
|
||||||
|
expect(result).toBe("needsAction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null status", () => {
|
||||||
|
const result = pipe["transformResponseStatus"](null);
|
||||||
|
expect(result).toBe("needsAction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined status", () => {
|
||||||
|
const result = pipe["transformResponseStatus"](undefined);
|
||||||
|
expect(result).toBe("needsAction");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transformEventStatus", () => {
|
||||||
|
it("should transform ACCEPTED status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
|
||||||
|
expect(result).toBe("confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform PENDING status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
|
||||||
|
expect(result).toBe("tentative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform CANCELLED status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
|
||||||
|
expect(result).toBe("cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transform DECLINED status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
|
||||||
|
expect(result).toBe("cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](null);
|
||||||
|
expect(result).toBe("confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined status", () => {
|
||||||
|
const result = pipe["transformEventStatus"](undefined);
|
||||||
|
expect(result).toBe("confirmed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
|
|||||||
|
|
||||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||||
|
|
||||||
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
|
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
|
||||||
|
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleCalendarService {
|
export class GoogleCalendarService {
|
||||||
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateEventDetails(
|
||||||
|
eventUid: string,
|
||||||
|
updateData: UpdateUnifiedCalendarEventInput
|
||||||
|
): Promise<GoogleCalendarEventResponse> {
|
||||||
|
const bookingReference =
|
||||||
|
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
|
||||||
|
|
||||||
|
if (!bookingReference) {
|
||||||
|
throw new NotFoundException("Booking reference not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerUserEmail = bookingReference?.booking?.user?.email;
|
||||||
|
|
||||||
|
const calendar = await this.getAuthorizedCalendarInstance(
|
||||||
|
ownerUserEmail,
|
||||||
|
bookingReference.credential?.key,
|
||||||
|
bookingReference.delegationCredential
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await calendar.events.patch({
|
||||||
|
calendarId: bookingReference?.externalCalendarId ?? "primary",
|
||||||
|
eventId: bookingReference?.uid,
|
||||||
|
requestBody: updatePayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event.data) {
|
||||||
|
throw new NotFoundException("Failed to update meeting");
|
||||||
|
}
|
||||||
|
return event.data as GoogleCalendarEventResponse;
|
||||||
|
} catch (error) {
|
||||||
|
throw new NotFoundException("Failed to update meeting details");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets an authorized Google Calendar instance
|
* Gets an authorized Google Calendar instance
|
||||||
* Tries delegation credentials first, falls back to direct OAuth
|
* Tries delegation credentials first, falls back to direct OAuth
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventTypeOwnershipGuard implements CanActivate {
|
||||||
|
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const user = request.user as ApiAuthGuardUser | undefined;
|
||||||
|
const eventTypeIdParam = request.params?.eventTypeId;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException("EventTypeOwnershipGuard - No user associated with the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventTypeIdParam) {
|
||||||
|
throw new BadRequestException("Missing eventTypeId param.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypeId = Number(eventTypeIdParam);
|
||||||
|
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
|
||||||
|
throw new BadRequestException("Invalid eventTypeId param.");
|
||||||
|
}
|
||||||
|
const eventType = await this.eventTypesService.getUserEventType(user.id, eventTypeId);
|
||||||
|
if (!eventType) {
|
||||||
|
// Mirrors EventTypesService behavior: NotFound when not owned or not present
|
||||||
|
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-4
@@ -63,6 +63,7 @@ describe("Assign all team members", () => {
|
|||||||
let firstManagedUser: CreateManagedUserData;
|
let firstManagedUser: CreateManagedUserData;
|
||||||
let secondManagedUser: CreateManagedUserData;
|
let secondManagedUser: CreateManagedUserData;
|
||||||
|
|
||||||
|
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
|
||||||
let roundRobinEventType: TeamEventTypeOutput_2024_06_14;
|
let roundRobinEventType: TeamEventTypeOutput_2024_06_14;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -315,6 +316,7 @@ describe("Assign all team members", () => {
|
|||||||
const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id);
|
const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||||
expect(firstHost).toBeDefined();
|
expect(firstHost).toBeDefined();
|
||||||
expect(secondHost).toBeDefined();
|
expect(secondHost).toBeDefined();
|
||||||
|
collectiveEventType = data;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,6 +367,28 @@ describe("Assign all team members", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should update round robin event type", async () => {
|
it("should update round robin event type", async () => {
|
||||||
|
if (!roundRobinEventType) {
|
||||||
|
const setupBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Coding consultation round robin",
|
||||||
|
slug: `assign-all-team-members-round-robin-${randomString()}`,
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
schedulingType: "roundRobin",
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupResponse = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`)
|
||||||
|
.send(setupBody)
|
||||||
|
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const setupResponseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = setupResponse.body;
|
||||||
|
roundRobinEventType = setupResponseBody.data;
|
||||||
|
}
|
||||||
|
|
||||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||||
title: "Coding consultation round robin updated",
|
title: "Coding consultation round robin updated",
|
||||||
};
|
};
|
||||||
@@ -467,10 +491,17 @@ describe("Assign all team members", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
function evaluateHost(expected: Partial<Host>, received: Host | undefined) {
|
||||||
expect(expected.userId).toEqual(received?.userId);
|
if (!received) {
|
||||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
throw new Error(`Host is undefined. Expected userId: ${expected.userId}`);
|
||||||
expect(expected.priority).toEqual(received?.priority);
|
}
|
||||||
|
expect(expected.userId).toEqual(received.userId);
|
||||||
|
if (expected.mandatory !== undefined) {
|
||||||
|
expect(expected.mandatory).toEqual(received.mandatory);
|
||||||
|
}
|
||||||
|
if (expected.priority !== undefined) {
|
||||||
|
expect(expected.priority).toEqual(received.priority);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
+50
@@ -1158,6 +1158,56 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create a managed team event-type without hosts", async () => {
|
||||||
|
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||||
|
title: "Coding consultation managed without hosts",
|
||||||
|
slug: "coding-consultation-managed-without-hosts",
|
||||||
|
description: "Our team will review your codebase.",
|
||||||
|
lengthInMinutes: 60,
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
type: "integration",
|
||||||
|
integration: "cal-video",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schedulingType: "MANAGED",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
|
||||||
|
const data = responseBody.data;
|
||||||
|
expect(data.length).toEqual(1);
|
||||||
|
|
||||||
|
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
|
||||||
|
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
|
||||||
|
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||||
|
|
||||||
|
const teammate1HasThisEvent = teammate1EventTypes.some((eventType) => eventType.slug === body.slug);
|
||||||
|
const teammate2HasThisEvent = teammate2EventTypes.some((eventType) => eventType.slug === body.slug);
|
||||||
|
expect(teammate1HasThisEvent).toBe(false);
|
||||||
|
expect(teammate2HasThisEvent).toBe(false);
|
||||||
|
expect(
|
||||||
|
teamEventTypes.filter(
|
||||||
|
(eventType) => eventType.schedulingType === "MANAGED" && eventType.slug === body.slug
|
||||||
|
).length
|
||||||
|
).toEqual(1);
|
||||||
|
|
||||||
|
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||||
|
expect(responseTeamEvent).toBeDefined();
|
||||||
|
expect(responseTeamEvent?.hosts).toEqual([]);
|
||||||
|
|
||||||
|
if (!responseTeamEvent) {
|
||||||
|
throw new Error("Team event not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||||
expect(expected.userId).toEqual(received?.userId);
|
expect(expected.userId).toEqual(received?.userId);
|
||||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class InputOrganizationsEventTypesService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const children = await this.getChildEventTypesForManagedEventType(null, inputEventType, teamId);
|
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
||||||
|
|
||||||
const metadata =
|
const metadata =
|
||||||
rest.schedulingType === "MANAGED"
|
rest.schedulingType === "MANAGED"
|
||||||
@@ -159,7 +159,11 @@ export class InputOrganizationsEventTypesService {
|
|||||||
throw new BadRequestException("Event type to update not found");
|
throw new BadRequestException("Event type to update not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
|
const children =
|
||||||
|
dbEventType.schedulingType === "MANAGED"
|
||||||
|
? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const teamEventType = {
|
const teamEventType = {
|
||||||
...eventType,
|
...eventType,
|
||||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||||
@@ -176,20 +180,17 @@ export class InputOrganizationsEventTypesService {
|
|||||||
return teamEventType;
|
return teamEventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChildEventTypesForManagedEventType(
|
async getChildEventTypesForManagedEventTypeUpdate(
|
||||||
eventTypeId: number | null,
|
eventTypeId: number,
|
||||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||||
teamId: number
|
teamId: number
|
||||||
) {
|
) {
|
||||||
let eventType = null;
|
const eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
|
||||||
if (eventTypeId) {
|
if (!eventType || eventType.schedulingType !== "MANAGED") {
|
||||||
eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
|
return undefined;
|
||||||
if (!eventType || eventType.schedulingType !== "MANAGED") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownersIds = await this.getOwnersIdsForManagedEventType(teamId, inputEventType, eventType);
|
const ownersIds = await this.getOwnersIdsForManagedEventTypeUpdate(teamId, inputEventType, eventType);
|
||||||
const owners = await this.getOwnersForManagedEventType(ownersIds);
|
const owners = await this.getOwnersForManagedEventType(ownersIds);
|
||||||
|
|
||||||
return owners.map((owner) => {
|
return owners.map((owner) => {
|
||||||
@@ -200,22 +201,58 @@ export class InputOrganizationsEventTypesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOwnersIdsForManagedEventType(
|
async getOwnersIdsForManagedEventTypeUpdate(
|
||||||
teamId: number,
|
teamId: number,
|
||||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||||
eventType: { children: { userId: number | null }[] } | null
|
eventType: { children: { userId: number | null }[] }
|
||||||
) {
|
) {
|
||||||
if (inputEventType.assignAllTeamMembers) {
|
if (inputEventType.assignAllTeamMembers) {
|
||||||
return await this.getTeamUsersIds(teamId);
|
return await this.getTeamUsersIds(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// note(Lauris): when API user updates managed event type users
|
|
||||||
if (inputEventType.hosts) {
|
if (inputEventType.hosts) {
|
||||||
return inputEventType.hosts.map((host) => host.userId);
|
return inputEventType.hosts.map((host) => host.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update
|
// note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update
|
||||||
return eventType?.children.map((child) => child.userId).filter((id) => !!id) as number[];
|
// e.g if managed event type title is changed then all children managed event types should be updated as well.
|
||||||
|
const childrenOwnersIds: number[] = [];
|
||||||
|
for (const child of eventType.children) {
|
||||||
|
if (child.userId) {
|
||||||
|
childrenOwnersIds.push(child.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return childrenOwnersIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildEventTypesForManagedEventTypeCreate(
|
||||||
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||||
|
teamId: number
|
||||||
|
) {
|
||||||
|
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
||||||
|
const owners = await this.getOwnersForManagedEventType(ownersIds);
|
||||||
|
|
||||||
|
return owners.map((owner) => {
|
||||||
|
return {
|
||||||
|
hidden: false,
|
||||||
|
owner,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwnersIdsForManagedEventTypeCreate(
|
||||||
|
teamId: number,
|
||||||
|
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||||
|
) {
|
||||||
|
if (inputEventType.assignAllTeamMembers) {
|
||||||
|
return await this.getTeamUsersIds(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputEventType.hosts) {
|
||||||
|
return inputEventType.hosts.map((host) => host.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTeamUsersIds(teamId: number) {
|
async getTeamUsersIds(teamId: number) {
|
||||||
@@ -234,7 +271,7 @@ export class InputOrganizationsEventTypesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getOwnersForManagedEventType(userIds: number[]) {
|
async getOwnersForManagedEventType(userIds: number[]) {
|
||||||
const users = await this.usersRepository.findByIdsWithEventTypes(userIds);
|
const users = userIds.length ? await this.usersRepository.findByIdsWithEventTypes(userIds) : [];
|
||||||
|
|
||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
const nonManagedEventTypes = user.eventTypes.filter((eventType) => !eventType.parentId);
|
const nonManagedEventTypes = user.eventTypes.filter((eventType) => !eventType.parentId);
|
||||||
|
|||||||
+11
@@ -27,6 +27,17 @@ export class OrganizationsMembershipService {
|
|||||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isOrgAdminOrOwner(organizationId: number, userId: number) {
|
||||||
|
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||||
|
organizationId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return membership.role === "ADMIN" || membership.role === "OWNER";
|
||||||
|
}
|
||||||
|
|
||||||
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||||
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
-1
@@ -3,7 +3,6 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
|||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { SkipTakePagination } from "@calcom/platform-types";
|
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user