Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73f7f4a28 | ||
|
|
e4ebe25626 | ||
|
|
7b6999831a | ||
|
|
0386723ae8 | ||
|
|
6615b885d1 | ||
|
|
254a2e310b | ||
|
|
1934c96353 | ||
|
|
a43437b1d6 | ||
|
|
d25094a152 | ||
|
|
70d29ab6fa | ||
|
|
65abf4edc6 | ||
|
|
ad4aeee45b | ||
|
|
40a2ec8ab3 | ||
|
|
2b7a87a34c | ||
|
|
b15060e306 | ||
|
|
bdf3935704 | ||
|
|
7952cfc9e9 | ||
|
|
4383d23d4c | ||
|
|
027cad2cfd | ||
|
|
b17b5a169f | ||
|
|
9eb1fe06e3 | ||
|
|
007349a180 | ||
|
|
46f979ba6d | ||
|
|
d4eb8e4b3a | ||
|
|
9d0afbd6f3 | ||
|
|
821bd37b37 | ||
|
|
2ab34004a2 | ||
|
|
3ee8032538 | ||
|
|
9838488068 | ||
|
|
1c365dee38 | ||
|
|
a77ee7aaa2 | ||
|
|
07d8a39829 | ||
|
|
10fa080b7f | ||
|
|
eb19edfa8f | ||
|
|
3719bb3762 | ||
|
|
c43c5a0604 | ||
|
|
1467b0b768 | ||
|
|
d3d4a90776 | ||
|
|
5ae50f8670 | ||
|
|
a3642a5594 | ||
|
|
96bfb19769 | ||
|
|
5935cf7f1b | ||
|
|
70eff63529 | ||
|
|
3d02df3b08 | ||
|
|
b9b12256fa | ||
|
|
1b051d102c | ||
|
|
76a820f3ca | ||
|
|
b3d5a6edca | ||
|
|
4753bd785a | ||
|
|
1f634b7ca0 | ||
|
|
2e0f4d3176 | ||
|
|
c2fd990504 | ||
|
|
ea3f4fedb0 | ||
|
|
2db4222ffe | ||
|
|
1dc5cd4d41 | ||
|
|
35d54ff223 | ||
|
|
e4c46e3387 | ||
|
|
77d1a873a9 | ||
|
|
54d98e73a2 | ||
|
|
134086f229 | ||
|
|
e9b5aa6f22 | ||
|
|
3908a6cd6f | ||
|
|
886028c400 | ||
|
|
703e20a43a | ||
|
|
b6aedb830b | ||
|
|
46c7afade6 | ||
|
|
5c7d7579ee | ||
|
|
d158a1e007 | ||
|
|
1fb4147940 | ||
|
|
9cf76cbb2f | ||
|
|
6b00167657 | ||
|
|
592f44eb32 | ||
|
|
00afafb550 |
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: Cal.com/platform request
|
||||
about: Suggest a feature or bug report for cal.com/platform
|
||||
title: ""
|
||||
labels: ["platform", "🚨 needs approval"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what the problem is.
|
||||
For example, "I'm always frustrated when..."
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution here.)
|
||||
|
||||
### Describe alternatives you've considered
|
||||
|
||||
<!--
|
||||
Let us know about other solutions you've tried or researched.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Additional context
|
||||
|
||||
<!--
|
||||
Is there anything else you can add about the proposal?
|
||||
You might want to link to related issues here, if you haven't already.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Requirement/Document
|
||||
|
||||
<!--
|
||||
Is there any type of document that could support that feature?
|
||||
-->
|
||||
|
||||
(Share it here.)
|
||||
|
||||
---
|
||||
|
||||
##### House rules
|
||||
|
||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
||||
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
||||
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
@@ -1,3 +1,5 @@
|
||||
# deprecated - use smtp with tasker instead */
|
||||
|
||||
name: Cron - scheduleEmailReminders
|
||||
|
||||
on:
|
||||
|
||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+69
-156
@@ -1,43 +1,76 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
## House rules
|
||||
## House Rules (for PRs and Issues)
|
||||
|
||||
- Before submitting a new issue or PR, check if it already exists in [issues](https://github.com/calcom/cal.com/issues) or [PRs](https://github.com/calcom/cal.com/pulls).
|
||||
- GitHub issues: take note of the `🚨 needs approval` label.
|
||||
- **For Contributors**:
|
||||
- Feature Requests: Wait for a core member to approve and remove the `🚨 needs approval` label before you start coding or submit a PR.
|
||||
- Bugs, Security, Performance, Documentation, etc.: You can start coding immediately, even if the `🚨 needs approval` label is present. This label mainly concerns feature requests.
|
||||
- **Our Process**:
|
||||
- Issues from non-core members automatically receive the `🚨 needs approval` label.
|
||||
- We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
|
||||
### 👥 Prevent Work Duplication
|
||||
|
||||
Before submitting a new issue or PR, check if it already exists in the [Issues](https://github.com/calcom/cal.com/issues) or [Pull Requests](https://github.com/calcom/cal.com/pulls).
|
||||
|
||||
### ✅ Work Only on Approved Issues
|
||||
|
||||
For feature requests, please wait for a core team member to approve and remove the `🚨 needs approval` label before you start coding or submitting a PR.
|
||||
|
||||
For bugs, security, performance, documentation, etc., you can start coding immediately—even if the `🚨 needs approval` label is present.
|
||||
|
||||
We highly value new feature ideas, but to maintain consistency in the product direction, they must go through a review and approval process.
|
||||
|
||||
### 🚫 Don’t Just Drop a Link
|
||||
|
||||
Avoid posting third-party links (e.g., Slack threads or Linear tickets) without context. A GitHub issue or PR should stand on its own—reviewers shouldn’t have to chase information across multiple tools to understand the context.
|
||||
|
||||
### 👀 Think Like a Reviewer
|
||||
|
||||
Put yourself in the reviewer’s shoes. What would you want to know if reading this for the first time? Are there key decisions, goals, or constraints that need clarification? Does the PR assume knowledge that isn’t obvious? Are there related issues or previous PRs that should be linked?
|
||||
|
||||
### 🧵 Bring in Context from Private Channels
|
||||
|
||||
If the task originated from a private conversation (e.g., Slack), take a moment to extract the relevant details and include them in the GitHub issue or PR. Avoid sharing sensitive information, but make sure important reasoning is captured.
|
||||
|
||||
> Example:
|
||||
> “A user requested feature X to solve problem Y. I considered approaches A, B, and C, but chose C for the following reasons…”
|
||||
|
||||
### 📚 Treat It Like Documentation
|
||||
|
||||
GitHub is a shared source of truth. Every issue and PR contributes to the long-term understanding of the codebase. Write clearly enough that someone—possibly you—can revisit it months later and still understand what happened and why.
|
||||
|
||||
### ✅ Summarize Your PR at the Top
|
||||
|
||||
Even if the code changes are minor or self-explanatory, a short written summary helps reviewers quickly understand the intent. You can use GitHub Copilot’s auto-summarize feature, but make sure to verify it for accuracy and relevance.
|
||||
|
||||
### 🔗 Use GitHub Keywords to Auto-Link Issues
|
||||
|
||||
Use phrases like “Closes #123” or “Fixes #456” in your PR descriptions. This automatically links your PR to the related issue and closes it once merged—keeping everything traceable and organized.
|
||||
|
||||
### 🧪 Mention What Was Tested (and How)
|
||||
|
||||
Explain how you validated your changes. It doesn’t need to be exhaustive—just enough to give reviewers confidence that things were tested and work as expected.
|
||||
|
||||
> Example:
|
||||
> “Tested locally with mock data and confirmed the flow works on staging.”
|
||||
|
||||
### 🧠 Assume Future-You Won’t Remember
|
||||
|
||||
Write with the future in mind. If there are trade-offs, edge cases, or temporary workarounds, document them clearly so they don’t get lost or misinterpreted later.
|
||||
|
||||
## Priorities
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
Type of Issue
|
||||
</td>
|
||||
<td>
|
||||
Priority
|
||||
</td>
|
||||
<td><strong>Type of Issue</strong></td>
|
||||
<td><strong>Priority</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Minor improvements, non-core feature requests
|
||||
</td>
|
||||
<td>Minor improvements, non-core feature requests</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22Low+priority%22">
|
||||
<img src="https://img.shields.io/badge/-Low%20Priority-green">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Confusing UX (... but working)
|
||||
</td>
|
||||
<tr>
|
||||
<td>Confusing UX (but still functional)</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22Medium+priority%22">
|
||||
<img src="https://img.shields.io/badge/-Medium%20Priority-yellow">
|
||||
@@ -45,9 +78,7 @@ Contributions are what makes the open source community such an amazing place to
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Features (Booking page, availability, timezone calculation)
|
||||
</td>
|
||||
<td>Core Features (Booking page, availability, timezone calculation)</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||
<img src="https://img.shields.io/badge/-High%20Priority-orange">
|
||||
@@ -55,9 +86,7 @@ Contributions are what makes the open source community such an amazing place to
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Bugs (Login, Booking page, Emails are not working)
|
||||
</td>
|
||||
<td>Core Bugs (Login, Booking page, Emails not working)</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:Urgent">
|
||||
<img src="https://img.shields.io/badge/-Urgent-red">
|
||||
@@ -68,59 +97,7 @@ Contributions are what makes the open source community such an amazing place to
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
requests should be made against. The changes on the `main`
|
||||
branch are tagged into a release monthly.
|
||||
|
||||
To develop locally:
|
||||
|
||||
1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
|
||||
own GitHub account and then
|
||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch:
|
||||
|
||||
```sh
|
||||
git checkout -b MY_BRANCH_NAME
|
||||
```
|
||||
|
||||
3. Install yarn:
|
||||
|
||||
```sh
|
||||
npm install -g yarn
|
||||
```
|
||||
|
||||
4. Install the dependencies with:
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
5. Set up your `.env` file:
|
||||
|
||||
- Duplicate `.env.example` to `.env`.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
6. Setup Node
|
||||
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||
|
||||
```sh
|
||||
nvm use
|
||||
```
|
||||
|
||||
You first might need to install the specific version:
|
||||
|
||||
```sh
|
||||
nvm install
|
||||
```
|
||||
|
||||
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||
|
||||
7. Start developing and watch for code changes:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
```
|
||||
[See README](https://github.com/calcom/cal.com#development)
|
||||
|
||||
## Building
|
||||
|
||||
@@ -130,23 +107,19 @@ You can build the project with:
|
||||
yarn build
|
||||
```
|
||||
|
||||
Please be sure that you can make a full production build before pushing code.
|
||||
Please ensure that you can make a full production build before pushing code.
|
||||
|
||||
## Testing
|
||||
|
||||
More info on how to add new tests coming soon.
|
||||
|
||||
### Running tests
|
||||
### Running Tests
|
||||
|
||||
This will run and test all flows in multiple Chromium windows to verify that no critical flow breaks:
|
||||
[See README](https://github.com/calcom/cal.com#e2e-testing)
|
||||
|
||||
```sh
|
||||
yarn test-e2e
|
||||
```
|
||||
#### Resolving Issues
|
||||
|
||||
#### Resolving issues
|
||||
|
||||
##### E2E test browsers not installed
|
||||
##### E2E Test Browsers Not Installed
|
||||
|
||||
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
|
||||
|
||||
@@ -166,68 +139,8 @@ If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||
|
||||
## Guidelines for committing yarn lockfile
|
||||
|
||||
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
|
||||
|
||||
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||
|
||||
```bash
|
||||
git checkout HEAD~1 yarn.lock
|
||||
git commit -m "Revert yarn.lock changes"
|
||||
```
|
||||
|
||||
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
|
||||
If you've pushed the commit with the `yarn.lock`:
|
||||
|
||||
1. Correct the commit locally using the above method.
|
||||
2. Carefully force push:
|
||||
|
||||
```bash
|
||||
git push origin <your-branch-name> --force
|
||||
```
|
||||
|
||||
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
||||
|
||||
1. **Checkout a Previous Version**:
|
||||
|
||||
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
||||
|
||||
```bash
|
||||
git log yarn.lock
|
||||
```
|
||||
|
||||
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
|
||||
|
||||
```bash
|
||||
git checkout <commit_hash> yarn.lock
|
||||
```
|
||||
|
||||
2. **Commit the Reverted Version**:
|
||||
|
||||
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||
|
||||
```bash
|
||||
git commit -m "Revert yarn.lock to its state before unintended changes"
|
||||
```
|
||||
|
||||
3. **Proceed with Caution**:
|
||||
|
||||
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
||||
|
||||
```bash
|
||||
git pull origin <your-branch-name>
|
||||
```
|
||||
|
||||
- Then push the updated branch:
|
||||
|
||||
```bash
|
||||
git push origin <your-branch-name>
|
||||
```
|
||||
|
||||
Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR).
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) when creating your PR. (This option isn't available if you're [contributing from a fork belonging to an organization](https://github.com/orgs/community/discussions/5634))
|
||||
- If your PR refers to or fixes an issue, add `refs #XXX` or `fixes #XXX` to the PR description. Replace `XXX` with the respective issue number. See more about [linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Fill out the PR template accordingly.
|
||||
- Review the [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations.
|
||||
- Lastly, make sure to keep your branches updated (e.g., click the `Update branch` button on the GitHub PR page).
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
|
||||
import { ZInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -38,7 +39,8 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
const createCaller = createCallerFactory(viewerTeamsRouter);
|
||||
const caller = createCaller(ctx);
|
||||
await caller.inviteMember({
|
||||
role: data.role,
|
||||
language: data.language,
|
||||
|
||||
@@ -3,7 +3,6 @@ import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
@@ -18,15 +17,8 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
let isColdStart = true;
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (isColdStart && IS_PRODUCTION) {
|
||||
console.log("Cold start of /v1/slots detected");
|
||||
isColdStart = false;
|
||||
}
|
||||
|
||||
const { usernameList, isTeamEvent, ...rest } = req.query;
|
||||
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
|
||||
let slugs = usernameList;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -39,7 +40,8 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
const createCaller = createCallerFactory(viewerTeamsRouter);
|
||||
const caller = createCaller(ctx);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.publish(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.171",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.172",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -256,7 +256,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Mark a booking absence - provided authorization header refers to owner of the booking.",
|
||||
summary: "Mark a booking absence",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -277,8 +278,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Automatically reassign booking to a new host automatically - provided authorization header refers to person who reassigned the booking.",
|
||||
summary: "Reassign a booking to auto-selected host",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -298,8 +299,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reassign a booking to a specific user specified by the :userId - provided authorization header refers to person who reassigned the booking.",
|
||||
summary: "Reassign a booking to a specific host",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -326,8 +327,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm booking that requires a confirmation - provided authorization header refers to owner of the booking.",
|
||||
summary: "Confirm a booking",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async confirmBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -347,8 +348,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Decline booking that requires a confirmation - provided authorization header refers to owner of the booking.",
|
||||
summary: "Decline a booking",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async declineBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
|
||||
@@ -54,12 +54,13 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
const { context } = requestProps;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.verbose(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
@@ -209,6 +210,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
protected async getTracker(request: Request): Promise<string> {
|
||||
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
|
||||
if (authorizationHeader) {
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
@@ -223,8 +225,8 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
return `oauth_client_${oauthClientId}`;
|
||||
}
|
||||
|
||||
if (request.ip) {
|
||||
return `ip_${request.ip}`;
|
||||
if (IP) {
|
||||
return `ip_${IP}`;
|
||||
}
|
||||
|
||||
this.logger.verbose(`no tracker found: ${request.url}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -19,6 +20,7 @@ import { Module } from "@nestjs/common";
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
ConferencingAtomsService,
|
||||
AttributesAtomsService,
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
BulkUpdateEventTypeToDefaultLocationDto,
|
||||
EventTypesAppInput,
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "@/modules/atoms/inputs/find-team-members-matching-attribute.input";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
@@ -34,6 +36,8 @@ import { ConnectedApps } from "@calcom/platform-libraries/app-store";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeResponseDto } from "../outputs/find-team-members-matching-attribute.output";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
@@ -50,7 +54,8 @@ these endpoints should not be recommended for use by third party and are exclude
|
||||
export class AtomsController {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesAtomService,
|
||||
private readonly conferencingService: ConferencingAtomsService
|
||||
private readonly conferencingService: ConferencingAtomsService,
|
||||
private readonly attributesService: AttributesAtomsService
|
||||
) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@@ -222,4 +227,25 @@ export class AtomsController {
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/members-matching-attribute")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async findTeamMembersMatchingAttributes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: FindTeamMembersMatchingAttributeQueryDto
|
||||
): Promise<FindTeamMembersMatchingAttributeResponseDto> {
|
||||
const result = await this.attributesService.findTeamMembersMatchingAttribute(teamId, orgId, {
|
||||
attributesQueryValue: query.attributesQueryValue,
|
||||
isPreview: query.isPreview,
|
||||
enablePerf: query.enablePerf,
|
||||
concurrency: query.concurrency,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/platform-libraries";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
})
|
||||
attributesQueryValue: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
})
|
||||
isPreview?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
})
|
||||
enablePerf?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
})
|
||||
concurrency?: number;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { FindTeamMembersMatchingAttributeOutputDto } from "@calcom/platform-types";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FindTeamMembersMatchingAttributeOutputDto)
|
||||
@Expose()
|
||||
@ApiProperty({ type: FindTeamMembersMatchingAttributeOutputDto })
|
||||
readonly data!: FindTeamMembersMatchingAttributeOutputDto;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { findTeamMembersMatchingAttributeLogic } from "@calcom/platform-libraries";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "../inputs/find-team-members-matching-attribute.input";
|
||||
|
||||
@Injectable()
|
||||
export class AttributesAtomsService {
|
||||
private logger = new Logger("AttributesAtomService");
|
||||
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async findTeamMembersMatchingAttribute(
|
||||
teamId: number,
|
||||
orgId: number,
|
||||
input: FindTeamMembersMatchingAttributeQueryDto
|
||||
) {
|
||||
const {
|
||||
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
|
||||
mainAttributeLogicBuildingWarnings: mainWarnings,
|
||||
fallbackAttributeLogicBuildingWarnings: fallbackWarnings,
|
||||
troubleshooter,
|
||||
} = await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
teamId,
|
||||
orgId,
|
||||
attributesQueryValue: input.attributesQueryValue,
|
||||
},
|
||||
{
|
||||
enablePerf: input.enablePerf,
|
||||
concurrency: input.concurrency,
|
||||
enableTroubleshooter: input.enablePerf,
|
||||
}
|
||||
);
|
||||
|
||||
if (!matchingTeamMembersWithResult) {
|
||||
return {
|
||||
troubleshooter,
|
||||
mainWarnings,
|
||||
fallbackWarnings,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
const matchingTeamMembersIds = matchingTeamMembersWithResult.map(
|
||||
(member: { userId: number }) => member.userId
|
||||
);
|
||||
|
||||
const matchingTeamMembers = await this.usersRepository.findByIds(matchingTeamMembersIds);
|
||||
|
||||
return {
|
||||
mainWarnings,
|
||||
fallbackWarnings,
|
||||
troubleshooter: troubleshooter,
|
||||
result: matchingTeamMembers.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
|
||||
import { OrganizationsRoutingFormsModule } from "@/modules/organizations/routing-forms/organizations-routing-forms.module";
|
||||
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
|
||||
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
|
||||
import { RouterModule } from "@/modules/router/router.module";
|
||||
@@ -31,6 +32,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
OrganizationsTeamsBookingsModule,
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
OrganizationsRoutingFormsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
|
||||
-4
@@ -305,8 +305,6 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
@@ -372,8 +370,6 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
|
||||
+14
-8
@@ -98,7 +98,10 @@ export class OAuthClientUsersController {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
user: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
...tokens,
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -162,7 +165,7 @@ export class OAuthClientUsersController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Force refresh tokens",
|
||||
description: `If you have lost managed user access or refresh token or the refresh token has expired, then you can get new ones by using OAuth credentials. ${TOKENS_DOCS}`,
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. ${TOKENS_DOCS}`,
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@@ -171,16 +174,19 @@ export class OAuthClientUsersController {
|
||||
): Promise<KeysResponseDto> {
|
||||
this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`);
|
||||
|
||||
await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
|
||||
userId,
|
||||
oAuthClientId
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.oAuthClientUsersService.getResponseOAuthTokens(accessToken, refreshToken),
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ import * as request from "supertest";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
@@ -204,12 +205,14 @@ describe("OAuthFlow Endpoints", () => {
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.userId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.userId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
+3
-4
@@ -33,8 +33,7 @@ import { Response as ExpressResponse } from "express";
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
export const TOKENS_DOCS = `Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model \`calAccessToken\` and \`calRefreshToken\` fields.
|
||||
Response also contains \`accessTokenExpiresAt\` and \`refreshTokenExpiresAt\` fields, but if you decode the jwt token the payload will contain \`clientId\` (OAuth client ID), \`ownerId\` (user to whom token belongs ID), \`iat\` (issued at time) and \`expiresAt\` (when does the token expire) fields.
|
||||
To learn about managing refresh tokens expiring after 1 year follow [this guide](https://cal.com/docs/platform/quickstart#5-managing-refresh-token-expiry)`;
|
||||
Response also contains \`accessTokenExpiresAt\` and \`refreshTokenExpiresAt\` fields, but if you decode the jwt token the payload will contain \`clientId\` (OAuth client ID), \`ownerId\` (user to whom token belongs ID), \`iat\` (issued at time) and \`expiresAt\` (when does the token expire) fields.`;
|
||||
|
||||
@Controller({
|
||||
path: "/v2/oauth/:clientId",
|
||||
@@ -95,7 +94,7 @@ export class OAuthFlowController {
|
||||
throw new BadRequestException("Missing 'Bearer' Authorization header.");
|
||||
}
|
||||
|
||||
const tokens = await this.oAuthFlowService.exchangeOAuthClientAuthorizationToken(
|
||||
const tokens = await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
@@ -126,7 +125,7 @@ export class OAuthFlowController {
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
): Promise<KeysResponseDto> {
|
||||
const tokens = await this.oAuthFlowService.refreshUserTokens(clientId, secretKey, body.refreshToken);
|
||||
const tokens = await this.oAuthFlowService.refreshToken(clientId, secretKey, body.refreshToken);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -3,13 +3,12 @@ import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger } from "@nestjs/common";
|
||||
import { User, CreationSource, PlatformOAuthClient, AccessToken, RefreshToken } from "@prisma/client";
|
||||
import { User, CreationSource, PlatformOAuthClient } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -79,10 +78,8 @@ export class OAuthClientUsersService {
|
||||
});
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
user.id,
|
||||
oAuthClientId
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, user.id);
|
||||
|
||||
if (oAuthClient.areDefaultEventTypesEnabled) {
|
||||
await this.eventTypesService.createUserDefaultEventTypes(user.id);
|
||||
@@ -102,16 +99,12 @@ export class OAuthClientUsersService {
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: this.getResponseOAuthTokens(accessToken, refreshToken),
|
||||
};
|
||||
}
|
||||
|
||||
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
refreshToken: refreshToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
tokens: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import {
|
||||
KeysDto,
|
||||
KeysResponseDto,
|
||||
} from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -7,7 +10,6 @@ import { BadRequestException, Injectable, Logger, UnauthorizedException } from "
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
||||
import { AccessToken, RefreshToken } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthFlowService {
|
||||
@@ -57,8 +59,8 @@ export class OAuthFlowService {
|
||||
|
||||
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
|
||||
|
||||
// await in case of race conditions
|
||||
await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb); // expires in 1 hour
|
||||
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
|
||||
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
|
||||
|
||||
return ownerIdFromDb;
|
||||
}
|
||||
@@ -83,8 +85,9 @@ export class OAuthFlowService {
|
||||
}
|
||||
|
||||
// we can't use a Promise#all or similar here because we care about execution order
|
||||
await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() });
|
||||
await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000));
|
||||
// however we can't allow caches to fail a validation hence the results are voided.
|
||||
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
|
||||
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -100,7 +103,7 @@ export class OAuthFlowService {
|
||||
return { status: "CACHE_MISS", cacheKey };
|
||||
}
|
||||
|
||||
async exchangeOAuthClientAuthorizationToken(
|
||||
async exchangeAuthorizationToken(
|
||||
tokenId: string,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
@@ -121,50 +124,46 @@ export class OAuthFlowService {
|
||||
throw new BadRequestException("Invalid Authorization Token.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
authorizationToken.owner.id,
|
||||
clientId
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(clientId, authorizationToken.owner.id);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
await this.propagateAccessToken(accessToken.secret);
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
|
||||
return this.getResponseOAuthTokens(accessToken, refreshToken);
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshUserTokens(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
userRefreshTokenSecret: string
|
||||
): Promise<KeysDto> {
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret(
|
||||
clientId,
|
||||
clientSecret,
|
||||
userRefreshTokenSecret
|
||||
tokenSecret
|
||||
);
|
||||
|
||||
if (!oauthClient) {
|
||||
throw new BadRequestException("Invalid OAuthClient credentials.");
|
||||
}
|
||||
|
||||
const currentUserRefreshToken = oauthClient.refreshToken[0];
|
||||
const currentRefreshToken = oauthClient.refreshToken[0];
|
||||
|
||||
if (!currentUserRefreshToken) {
|
||||
throw new BadRequestException("Invalid managed user refresh token");
|
||||
if (!currentRefreshToken) {
|
||||
throw new BadRequestException("Invalid refresh token");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
|
||||
currentUserRefreshToken.userId,
|
||||
clientId
|
||||
clientId,
|
||||
currentRefreshToken.secret,
|
||||
currentRefreshToken.userId
|
||||
);
|
||||
|
||||
return this.getResponseOAuthTokens(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
refreshToken: refreshToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export type OAuthCallbackState = {
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Conferencing")
|
||||
@DocsTags("Orgs / Teams / Conferencing")
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
|
||||
@@ -37,7 +37,7 @@ import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/s
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms-responses.module";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationsUsersController } from "@/modules/organizations/users/index/controllers/organizations-users.controller";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
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 { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let apiKeyString: string;
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
let routingFormResponse: App_RoutingForms_FormResponse;
|
||||
let routingFormResponse2: App_RoutingForms_FormResponse;
|
||||
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
|
||||
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() + 1);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: JSON.stringify([]),
|
||||
fields: JSON.stringify([]),
|
||||
settings: JSON.stringify({}),
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: { question1: "answer1", question2: "answer2" },
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
|
||||
where: {
|
||||
formId: routingForm.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||
it("should not get routing form responses for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not get routing form responses for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not get routing form responses without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should get routing form responses", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responses = responseBody.data as App_RoutingForms_FormResponse[];
|
||||
expect(responses).toBeDefined();
|
||||
expect(responses.length).toBeGreaterThan(0);
|
||||
expect(responses.find((response) => response.id === routingFormResponse.id)).toBeDefined();
|
||||
expect(responses.find((response) => response.id === routingFormResponse.id)?.formId).toEqual(
|
||||
routingFormResponse.formId
|
||||
);
|
||||
expect(responses.find((response) => response.id === routingFormResponse2.id)).toBeDefined();
|
||||
expect(responses.find((response) => response.id === routingFormResponse2.id)?.formId).toEqual(
|
||||
routingFormResponse2.formId
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponse.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing response", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/99999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
|
||||
)
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should update routing form response", async () => {
|
||||
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: updatedResponse })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toEqual(routingFormResponse.id);
|
||||
expect(data.formId).toEqual(routingFormResponse.formId);
|
||||
expect(data.response).toEqual(updatedResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
|
||||
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
|
||||
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
|
||||
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Orgs / Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsRoutingFormsResponsesController {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsResponsesService: OrganizationsRoutingFormsResponsesService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get routing form responses" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getRoutingFormResponses(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Query() queryParams: GetRoutingFormResponsesParams
|
||||
): Promise<GetRoutingFormResponsesOutput> {
|
||||
const { skip, take, ...filters } = queryParams;
|
||||
|
||||
const responses =
|
||||
await this.organizationsRoutingFormsResponsesService.getOrganizationRoutingFormResponses(
|
||||
orgId,
|
||||
routingFormId,
|
||||
skip ?? 0,
|
||||
take ?? 250,
|
||||
filters
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: responses,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async updateRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Param("responseId", ParseIntPipe) responseId: number,
|
||||
@Body() updateRoutingFormResponseInput: UpdateRoutingFormResponseInput
|
||||
): Promise<UpdateRoutingFormResponseOutput> {
|
||||
const updatedResponse = await this.organizationsRoutingFormsResponsesService.updateRoutingFormResponse(
|
||||
orgId,
|
||||
routingFormId,
|
||||
responseId,
|
||||
updateRoutingFormResponseInput
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { GetRoutingFormsOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
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 { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
describe("OrganizationsRoutingFormController", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let apiKeyString: string;
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
let routingFormResponse: App_RoutingForms_FormResponse;
|
||||
let routingFormResponse2: App_RoutingForms_FormResponse;
|
||||
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
|
||||
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [{ redirect: "http://google.com" }],
|
||||
fields: [{ territory: "input" }],
|
||||
settings: { test: "true" },
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`GET /v2/organizations/:orgId/routing-forms`, () => {
|
||||
it("should not get routing forms for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/99999/routing-forms`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not get routing forms without authentication", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/routing-forms`).expect(401);
|
||||
});
|
||||
|
||||
it("should get organization routing forms", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms?skip=0&take=1`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].id).toEqual(routingForm.id);
|
||||
expect(routingForms[0].name).toEqual(routingForm.name);
|
||||
expect(routingForms[0].description).toEqual(routingForm.description);
|
||||
expect(routingForms[0].disabled).toEqual(routingForm.disabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter routing forms by name", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms?name=Test`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].name).toContain("Test");
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter routing forms by disabled status", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms?disabled=false`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].disabled).toEqual(false);
|
||||
expect(routingForms[0].fields?.[0]).toEqual({ territory: "input" });
|
||||
expect(routingForms[0].routes?.[0]).toEqual({ redirect: "http://google.com" });
|
||||
expect(routingForms[0].settings).toEqual({ test: "true" });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { GetRoutingFormsParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||
import {
|
||||
GetRoutingFormsOutput,
|
||||
RoutingFormOutput,
|
||||
} from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
|
||||
import { OrganizationsRoutingFormsService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms.service";
|
||||
import { Controller, Get, Param, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/routing-forms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Orgs / Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsRoutingFormsController {
|
||||
constructor(private readonly organizationsRoutingFormsService: OrganizationsRoutingFormsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get organization routing forms" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getOrganizationRoutingForms(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: GetRoutingFormsParams
|
||||
): Promise<GetRoutingFormsOutput> {
|
||||
const { skip, take, ...filters } = queryParams;
|
||||
|
||||
const routingForms = await this.organizationsRoutingFormsService.getOrganizationRoutingForms(
|
||||
orgId,
|
||||
skip ?? 0,
|
||||
take ?? 250,
|
||||
filters
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: routingForms.map((form) => plainToClass(RoutingFormOutput, form)),
|
||||
};
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsDate,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
} from "class-validator";
|
||||
|
||||
enum SortOrder {
|
||||
ASC = "asc",
|
||||
DESC = "desc",
|
||||
}
|
||||
|
||||
export class GetRoutingFormResponsesParams {
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by creation time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by update time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses created after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterCreatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses created before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeCreatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses created after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterUpdatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses updated before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeUpdatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({ type: String, description: "Filter by responses routed to a specific booking" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
|
||||
export class GetRoutingFormsParams extends GetRoutingFormResponsesParams {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||
example: "?teamIds=100,200",
|
||||
})
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||
teamIds?: number[];
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsBoolean, IsString, IsEnum, IsDate, IsISO8601 } from "class-validator";
|
||||
|
||||
enum SortOrder {
|
||||
ASC = "asc",
|
||||
DESC = "desc",
|
||||
}
|
||||
|
||||
export class GetRoutingFormsParams {
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of routing forms to skip" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of routing forms to take" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by creation time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by update time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ type: Boolean, description: "Filter by disabled status" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
disabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: String, description: "Filter by name" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms created after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterCreatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms created before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeCreatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms updated after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterUpdatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms updated before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeUpdatedAt?: Date;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional } from "class-validator";
|
||||
|
||||
export class UpdateRoutingFormResponseInput {
|
||||
@ApiPropertyOptional({ type: Object, description: "The updated response data" })
|
||||
@IsOptional()
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
|
||||
import { OrganizationsRoutingFormsController } from "./controllers/organizations-routing-forms.controller";
|
||||
import { OrganizationsRoutingFormsRepository } from "./organizations-routing-forms.repository";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "./services/organizations-routing-forms-responses.service";
|
||||
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
providers: [
|
||||
MembershipsRepository,
|
||||
OrganizationsRepository,
|
||||
OrganizationsRoutingFormsRepository,
|
||||
OrganizationsRoutingFormsService,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
],
|
||||
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
|
||||
})
|
||||
export class OrganizationsRoutingFormsModule {}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getOrganizationRoutingForms(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
teamIds?: number[];
|
||||
}
|
||||
) {
|
||||
const {
|
||||
disabled,
|
||||
name,
|
||||
sortCreatedAt,
|
||||
sortUpdatedAt,
|
||||
afterCreatedAt,
|
||||
beforeCreatedAt,
|
||||
afterUpdatedAt,
|
||||
beforeUpdatedAt,
|
||||
teamIds,
|
||||
} = options || {};
|
||||
|
||||
return this.dbRead.prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
team: { parentId: orgId, ...(teamIds?.length ? { id: { in: teamIds } } : {}) },
|
||||
...(disabled !== undefined && { disabled }),
|
||||
...(name && { name: { contains: name, mode: "insensitive" } }),
|
||||
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
|
||||
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
|
||||
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
|
||||
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
|
||||
},
|
||||
orderBy: [
|
||||
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
|
||||
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
|
||||
],
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
sortCreatedAt,
|
||||
sortUpdatedAt,
|
||||
afterCreatedAt,
|
||||
beforeCreatedAt,
|
||||
routedToBookingUid,
|
||||
afterUpdatedAt,
|
||||
beforeUpdatedAt,
|
||||
} = options || {};
|
||||
await this.dbRead.prisma.app_RoutingForms_Form.findFirstOrThrow({
|
||||
where: {
|
||||
team: { parentId: orgId },
|
||||
id: routingFormId,
|
||||
},
|
||||
});
|
||||
|
||||
return this.dbRead.prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: routingFormId,
|
||||
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
|
||||
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
|
||||
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
|
||||
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
|
||||
...(routedToBookingUid && { routedToBookingUid }),
|
||||
},
|
||||
orderBy: [
|
||||
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
|
||||
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
|
||||
],
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoutingFormResponse(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
responseId: number,
|
||||
data: {
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
) {
|
||||
return this.dbWrite.prisma.app_RoutingForms_FormResponse.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
formId: routingFormId,
|
||||
form: {
|
||||
team: {
|
||||
parentId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { RoutingFormResponseOutput } from "@calcom/platform-types";
|
||||
|
||||
export class GetRoutingFormResponsesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: RoutingFormResponseOutput })
|
||||
@Expose()
|
||||
@Type(() => RoutingFormResponseOutput)
|
||||
data!: RoutingFormResponseOutput[];
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class RoutingFormOutput {
|
||||
@Expose()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "My Form" })
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "This is the description." })
|
||||
@Expose()
|
||||
description!: string | null;
|
||||
|
||||
@ApiProperty({ example: 0 })
|
||||
@Expose()
|
||||
position!: number;
|
||||
|
||||
@Expose()
|
||||
routes!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ example: "2024-03-28T10:00:00.000Z" })
|
||||
@Expose()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ example: "2024-03-28T10:00:00.000Z" })
|
||||
@Expose()
|
||||
updatedAt!: string;
|
||||
|
||||
@Expose()
|
||||
fields!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ example: 2313 })
|
||||
@Expose()
|
||||
userId!: number;
|
||||
|
||||
@ApiProperty({ example: 4214321 })
|
||||
@Expose()
|
||||
teamId!: number | null;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
@Expose()
|
||||
disabled!: boolean;
|
||||
|
||||
@Expose()
|
||||
settings!: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class GetRoutingFormsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: [RoutingFormOutput] })
|
||||
@Expose()
|
||||
@Type(() => RoutingFormOutput)
|
||||
data!: RoutingFormOutput[];
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { RoutingFormResponseOutput } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateRoutingFormResponseOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: RoutingFormResponseOutput })
|
||||
@Expose()
|
||||
@Type(() => RoutingFormResponseOutput)
|
||||
data!: RoutingFormResponseOutput;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
|
||||
) {}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
const responses = await this.organizationsRoutingFormsRepository.getOrganizationRoutingFormResponses(
|
||||
orgId,
|
||||
routingFormId,
|
||||
skip,
|
||||
take,
|
||||
options
|
||||
);
|
||||
|
||||
return this.outputService.getRoutingFormResponses(responses);
|
||||
}
|
||||
|
||||
async updateRoutingFormResponse(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
responseId: number,
|
||||
data: {
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
) {
|
||||
const updatedResponse = await this.organizationsRoutingFormsRepository.updateRoutingFormResponse(
|
||||
orgId,
|
||||
routingFormId,
|
||||
responseId,
|
||||
data
|
||||
);
|
||||
|
||||
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsService {
|
||||
constructor(private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository) {}
|
||||
|
||||
async getOrganizationRoutingForms(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
teamIds?: number[];
|
||||
}
|
||||
) {
|
||||
return this.organizationsRoutingFormsRepository.getOrganizationRoutingForms(orgId, skip, take, options);
|
||||
}
|
||||
}
|
||||
+60
-2
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
|
||||
let routingFormResponseId: string;
|
||||
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
@@ -173,7 +173,9 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
|
||||
it("should get routing form responses", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses`)
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?skip=0&take=1`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
@@ -186,9 +188,65 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
||||
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
||||
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
||||
routingFormResponseId = responseData[0].id;
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/99999/routing-forms/${routingFormId}/responses/${routingFormResponseId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponseId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing response", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/99999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/${routingFormResponseId}`
|
||||
)
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should update routing form response", async () => {
|
||||
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/${routingFormResponseId}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: updatedResponse })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toEqual(routingFormResponseId);
|
||||
expect(data.formId).toEqual(routingFormId);
|
||||
expect(data.response).toEqual(updatedResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
|
||||
+50
-12
@@ -8,14 +8,16 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
||||
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||
import { UpdateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/update-routing-form-response.input";
|
||||
import { UpdateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/update-routing-form-response.output";
|
||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses.service";
|
||||
import { Controller, Get, Patch, Param, ParseIntPipe, Query, UseGuards, Body } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { RoutingFormsResponsesService } from "../../../../routing-forms-responses/services/routing-forms-responses.service";
|
||||
import { GetRoutingFormResponsesOutput } from "../outputs/get-routing-form-responses.output";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/routing-forms/:routingFormId/responses",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -31,22 +33,58 @@ import { GetRoutingFormResponsesOutput } from "../outputs/get-routing-form-respo
|
||||
)
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsTeamsRoutingFormsResponsesController {
|
||||
constructor(private readonly routingFormsResponsesService: RoutingFormsResponsesService) {}
|
||||
constructor(
|
||||
private readonly organizationsTeamsRoutingFormsResponsesService: OrganizationsTeamsRoutingFormsResponsesService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get routing form responses" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team routing form responses" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getRoutingFormResponses(
|
||||
@Param("routingFormId") routingFormId: string
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetRoutingFormResponsesParams
|
||||
): Promise<GetRoutingFormResponsesOutput> {
|
||||
const routingFormResponses = await this.routingFormsResponsesService.getRoutingFormResponses(
|
||||
routingFormId
|
||||
);
|
||||
const { skip, take, ...filters } = queryParams;
|
||||
|
||||
const routingFormResponses =
|
||||
await this.organizationsTeamsRoutingFormsResponsesService.getTeamRoutingFormResponses(
|
||||
teamId,
|
||||
routingFormId,
|
||||
skip ?? 0,
|
||||
take ?? 250,
|
||||
{ ...(filters ?? {}) }
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: routingFormResponses,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async updateRoutingFormResponse(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Param("responseId", ParseIntPipe) responseId: number,
|
||||
@Body() updateRoutingFormResponseInput: UpdateRoutingFormResponseInput
|
||||
): Promise<UpdateRoutingFormResponseOutput> {
|
||||
const updatedResponse =
|
||||
await this.organizationsTeamsRoutingFormsResponsesService.updateTeamRoutingFormResponse(
|
||||
teamId,
|
||||
routingFormId,
|
||||
responseId,
|
||||
updateRoutingFormResponseInput
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { GetRoutingFormsOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
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 { App_RoutingForms_Form, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let apiKeyString: string;
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
|
||||
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() + 1);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: JSON.stringify([]),
|
||||
fields: JSON.stringify([]),
|
||||
settings: JSON.stringify({}),
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`GET /v2/organizations/:orgId/teams/:teamId/routing-forms`, () => {
|
||||
it("should not get team routing forms for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/99999/teams/${team.id}/routing-forms`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not get team routing forms for non existing team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/99999/routing-forms`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not get team routing forms without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should get team routing forms", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].id).toEqual(routingForm.id);
|
||||
expect(routingForms[0].name).toEqual(routingForm.name);
|
||||
expect(routingForms[0].description).toEqual(routingForm.description);
|
||||
expect(routingForms[0].disabled).toEqual(routingForm.disabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter team routing forms by name", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms?name=Team`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].name).toContain("Test Routing Form");
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter team routing forms by disabled status", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms?disabled=false`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].disabled).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||
import {
|
||||
GetRoutingFormsOutput,
|
||||
RoutingFormOutput,
|
||||
} from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
|
||||
import { OrganizationsTeamsRoutingFormsService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms.service";
|
||||
import { Controller, Get, Param, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/routing-forms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Orgs / Teams / Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsTeamsRoutingFormsController {
|
||||
constructor(
|
||||
private readonly organizationsTeamsRoutingFormsService: OrganizationsTeamsRoutingFormsService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get team routing forms" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getTeamRoutingForms(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetRoutingFormResponsesParams
|
||||
): Promise<GetRoutingFormsOutput> {
|
||||
const { skip, take, ...filters } = queryParams;
|
||||
|
||||
const routingForms = await this.organizationsTeamsRoutingFormsService.getTeamRoutingForms(
|
||||
teamId,
|
||||
skip ?? 0,
|
||||
take ?? 250,
|
||||
{ ...(filters ?? {}) }
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: routingForms.map((form) => plainToClass(RoutingFormOutput, form)),
|
||||
};
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsResponsesModule } from "@/modules/routing-forms-responses/routing-forms-responses.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsResponsesModule, RoutingFormsModule],
|
||||
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
|
||||
controllers: [OrganizationsTeamsRoutingFormsResponsesController],
|
||||
})
|
||||
export class OrganizationsTeamsRoutingFormsModule {}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
|
||||
import { OrganizationsTeamsRoutingFormsController } from "./controllers/organizations-teams-routing-forms.controller";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "./repositories/organizations-teams-routing-forms-responses.repository";
|
||||
import { OrganizationsTeamsRoutingFormsRepository } from "./repositories/organizations-teams-routing-forms.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "./services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organizations-teams-routing-forms-responses.service";
|
||||
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
providers: [
|
||||
OrganizationsTeamsRoutingFormsService,
|
||||
OrganizationsTeamsRoutingFormsResponsesService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
OrganizationsTeamsRoutingFormsResponsesRepository,
|
||||
OrganizationsTeamsRoutingFormsRepository,
|
||||
OrganizationsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
MembershipsRepository,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
OrganizationsRoutingFormsRepository,
|
||||
],
|
||||
controllers: [OrganizationsTeamsRoutingFormsResponsesController, OrganizationsTeamsRoutingFormsController],
|
||||
})
|
||||
export class OrganizationsTeamsRoutingFormsModule {}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRoutingFormsResponsesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getTeamRoutingFormResponses(
|
||||
teamId: number,
|
||||
routingFormId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
const { sortCreatedAt, afterCreatedAt, beforeCreatedAt, routedToBookingUid } = options || {};
|
||||
|
||||
return this.dbRead.prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: routingFormId,
|
||||
form: {
|
||||
teamId,
|
||||
},
|
||||
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
|
||||
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
|
||||
...(routedToBookingUid && { routedToBookingUid }),
|
||||
},
|
||||
orderBy: [...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : [])],
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamRoutingFormResponse(
|
||||
teamId: number,
|
||||
routingFormId: string,
|
||||
responseId: number,
|
||||
data: {
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
) {
|
||||
return this.dbWrite.prisma.app_RoutingForms_FormResponse.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
formId: routingFormId,
|
||||
form: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRoutingFormsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getTeamRoutingForms(
|
||||
teamId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
disabled,
|
||||
name,
|
||||
sortCreatedAt,
|
||||
sortUpdatedAt,
|
||||
afterCreatedAt,
|
||||
beforeCreatedAt,
|
||||
afterUpdatedAt,
|
||||
beforeUpdatedAt,
|
||||
} = options || {};
|
||||
|
||||
return this.dbRead.prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
...(disabled !== undefined && { disabled }),
|
||||
...(name && { name: { contains: name, mode: "insensitive" } }),
|
||||
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
|
||||
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
|
||||
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
|
||||
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
|
||||
},
|
||||
orderBy: [
|
||||
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
|
||||
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
|
||||
],
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { RoutingFormResponseOutput, RoutingFormResponseResponseOutput } from "@c
|
||||
import { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class RoutingFormsResponsesOutputService {
|
||||
export class OrganizationsTeamsRoutingFormsResponsesOutputService {
|
||||
getRoutingFormResponses(
|
||||
dbRoutingFormResponses: App_RoutingForms_FormResponse[]
|
||||
): RoutingFormResponseOutput[] {
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositories/organizations-teams-routing-forms-responses.repository";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsResponsesRepository,
|
||||
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService
|
||||
) {}
|
||||
|
||||
async getTeamRoutingFormResponses(
|
||||
teamId: number,
|
||||
routingFormId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
const responses = await this.routingFormsRepository.getTeamRoutingFormResponses(
|
||||
teamId,
|
||||
routingFormId,
|
||||
skip,
|
||||
take,
|
||||
options
|
||||
);
|
||||
|
||||
return this.routingFormsResponsesOutputService.getRoutingFormResponses(responses);
|
||||
}
|
||||
|
||||
async updateTeamRoutingFormResponse(
|
||||
teamId: number,
|
||||
routingFormId: string,
|
||||
responseId: number,
|
||||
data: {
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
) {
|
||||
const updatedResponse = await this.routingFormsRepository.updateTeamRoutingFormResponse(
|
||||
teamId,
|
||||
routingFormId,
|
||||
responseId,
|
||||
data
|
||||
);
|
||||
|
||||
return this.routingFormsResponsesOutputService.getRoutingFormResponses([updatedResponse])[0];
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsRepository } from "../repositories/organizations-teams-routing-forms.repository";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsRoutingFormsService {
|
||||
constructor(private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsRepository) {}
|
||||
|
||||
async getTeamRoutingForms(
|
||||
teamId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
}
|
||||
) {
|
||||
return this.routingFormsRepository.getTeamRoutingForms(teamId, skip, take, options);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RoutingFormsResponsesRepository } from "@/modules/routing-forms-responses/routing-forms-responses.repository";
|
||||
import { RoutingFormsResponsesOutputService } from "@/modules/routing-forms-responses/services/routing-forms-responses-output.service";
|
||||
import { RoutingFormsResponsesService } from "@/modules/routing-forms-responses/services/routing-forms-responses.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
RoutingFormsResponsesService,
|
||||
RoutingFormsResponsesRepository,
|
||||
RoutingFormsResponsesOutputService,
|
||||
],
|
||||
exports: [
|
||||
RoutingFormsResponsesService,
|
||||
RoutingFormsResponsesRepository,
|
||||
RoutingFormsResponsesOutputService,
|
||||
],
|
||||
})
|
||||
export class RoutingFormsResponsesModule {}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class RoutingFormsResponsesRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
|
||||
async getRoutingFormResponses(routingFormId: string) {
|
||||
return this.dbRead.prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: routingFormId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { RoutingFormsResponsesOutputService } from "@/modules/routing-forms-responses/services/routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RoutingFormsResponsesRepository } from "../routing-forms-responses.repository";
|
||||
|
||||
@Injectable()
|
||||
export class RoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly routingFormsRepository: RoutingFormsResponsesRepository,
|
||||
private readonly routingFormsResponsesOutputService: RoutingFormsResponsesOutputService
|
||||
) {}
|
||||
|
||||
async getRoutingFormResponses(routingFormId: string) {
|
||||
const responses = await this.routingFormsRepository.getRoutingFormResponses(routingFormId);
|
||||
return this.routingFormsResponsesOutputService.getRoutingFormResponses(responses);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,54 @@ export class TokensRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
|
||||
if (deleteOld) {
|
||||
try {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
}
|
||||
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessTokenExpiryDate(accessTokenSecret: string) {
|
||||
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
|
||||
where: {
|
||||
@@ -75,57 +123,46 @@ export class TokensRepository {
|
||||
return accessToken?.userId;
|
||||
}
|
||||
|
||||
async refreshOAuthTokens(ownerId: number, clientId: string) {
|
||||
try {
|
||||
await this.deleteOAuthTokens(ownerId, clientId);
|
||||
} catch (err) {
|
||||
this.logger.error("refreshOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
|
||||
return await this.createOAuthTokens(ownerId, clientId);
|
||||
}
|
||||
|
||||
private async deleteOAuthTokens(ownerId: number, clientId: string) {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async createOAuthTokens(ownerId: number, clientId: string) {
|
||||
async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, expiresAt: { lte: new Date() } },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }),
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
secret: this.jwtService.signAccessToken({
|
||||
clientId,
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
owner: { connect: { id: tokenUserId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
|
||||
// backwards compatability.
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
owner: { connect: { id: tokenUserId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async getAccessTokenClient(accessToken: string) {
|
||||
|
||||
+1095
-304
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,10 @@ export default async function BookingPageWrapperLayout({ children }: { children:
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper isBookingPage={true} requiresLicense={false} nonce={nonce}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<PageWrapper isBookingPage={true} requiresLicense={false} nonce={nonce}>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -17,7 +20,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const _searchParams = await searchParams;
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, _searchParams);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -26,12 +30,16 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
const initialData = await caller.getUserEventGroups({ filters });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("event_types_page_title")}
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
CTA={<EventTypesCTA />}>
|
||||
<EventTypes />
|
||||
CTA={<EventTypesCTA initialData={initialData} filters={filters} />}>
|
||||
<EventTypes initialData={initialData} filters={filters} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,19 +21,21 @@ export default async function PageWrapperLayout({ children }: { children: React.
|
||||
].filter((script): script is { id: string; script: string } => !!script.script);
|
||||
|
||||
return (
|
||||
<PageWrapper requiresLicense={false} nonce={nonce}>
|
||||
{children}
|
||||
{scripts.map((script) => (
|
||||
<Script
|
||||
key={script.id}
|
||||
nonce={nonce}
|
||||
id={script.id}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: script.script,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PageWrapper>
|
||||
<>
|
||||
<PageWrapper requiresLicense={false} nonce={nonce}>
|
||||
{children}
|
||||
{scripts.map((script) => (
|
||||
<Script
|
||||
key={script.id}
|
||||
nonce={nonce}
|
||||
id={script.id}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: script.script,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PageWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+74
-44
@@ -199,9 +199,7 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
|
||||
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
|
||||
// TODO: figure out feature flag as it doesnt cause a re-render of the component when loaded.
|
||||
// You have to refresh the page to see the changes.
|
||||
if (true) {
|
||||
if (isOrgAdminOrOwner) {
|
||||
newArray.splice(4, 0, {
|
||||
name: "attributes",
|
||||
href: "/settings/organizations/attributes",
|
||||
@@ -316,27 +314,41 @@ const TeamListCollapsible = () => {
|
||||
className="cursor-pointer"
|
||||
key={team.id}
|
||||
open={teamMenuState[index].teamMenuOpen}
|
||||
onOpenChange={() =>
|
||||
setTeamMenuState([
|
||||
...teamMenuState,
|
||||
(teamMenuState[index] = {
|
||||
...teamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
onOpenChange={(open) => {
|
||||
const newTeamMenuState = [...teamMenuState];
|
||||
newTeamMenuState[index] = {
|
||||
...newTeamMenuState[index],
|
||||
teamMenuOpen: open,
|
||||
};
|
||||
setTeamMenuState(newTeamMenuState);
|
||||
}}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
<button
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none transition"
|
||||
onClick={() =>
|
||||
setTeamMenuState([
|
||||
...teamMenuState,
|
||||
(teamMenuState[index] = {
|
||||
...teamMenuState[index],
|
||||
aria-controls={`team-content-${team.id}`}
|
||||
aria-expanded={teamMenuState[index].teamMenuOpen}
|
||||
onClick={() => {
|
||||
const newTeamMenuState = [...teamMenuState];
|
||||
newTeamMenuState[index] = {
|
||||
...newTeamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
};
|
||||
setTeamMenuState(newTeamMenuState);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const newTeamMenuState = [...teamMenuState];
|
||||
newTeamMenuState[index] = {
|
||||
...newTeamMenuState[index],
|
||||
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
};
|
||||
setTeamMenuState(newTeamMenuState);
|
||||
}
|
||||
}}
|
||||
aria-label={`${team.name} ${
|
||||
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
|
||||
}`}>
|
||||
<div className="me-3">
|
||||
{teamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
@@ -357,9 +369,9 @@ const TeamListCollapsible = () => {
|
||||
Inv.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5">
|
||||
<CollapsibleContent className="space-y-0.5" id={`team-content-${team.id}`}>
|
||||
{team.accepted && (
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
@@ -486,7 +498,7 @@ const SettingsSidebarContainer = ({
|
||||
? "translate-x-0 opacity-100"
|
||||
: "-translate-x-full opacity-0 lg:translate-x-0 lg:opacity-100"
|
||||
)}
|
||||
aria-label="Tabs">
|
||||
aria-label={t("settings_navigation")}>
|
||||
<>
|
||||
<BackButtonInSidebar name={t("back")} />
|
||||
{tabsWithPermissions.map((tab) => {
|
||||
@@ -602,27 +614,43 @@ const SettingsSidebarContainer = ({
|
||||
className="cursor-pointer"
|
||||
key={otherTeam.id}
|
||||
open={otherTeamMenuState[index].teamMenuOpen}
|
||||
onOpenChange={() =>
|
||||
setOtherTeamMenuState([
|
||||
...otherTeamMenuState,
|
||||
(otherTeamMenuState[index] = {
|
||||
...otherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
onOpenChange={(open) => {
|
||||
const newOtherTeamMenuState = [...otherTeamMenuState];
|
||||
newOtherTeamMenuState[index] = {
|
||||
...newOtherTeamMenuState[index],
|
||||
teamMenuOpen: open,
|
||||
};
|
||||
setOtherTeamMenuState(newOtherTeamMenuState);
|
||||
}}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
<button
|
||||
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none transition"
|
||||
onClick={() =>
|
||||
setOtherTeamMenuState([
|
||||
...otherTeamMenuState,
|
||||
(otherTeamMenuState[index] = {
|
||||
...otherTeamMenuState[index],
|
||||
aria-controls={`other-team-content-${otherTeam.id}`}
|
||||
aria-expanded={otherTeamMenuState[index].teamMenuOpen}
|
||||
onClick={() => {
|
||||
const newOtherTeamMenuState = [...otherTeamMenuState];
|
||||
newOtherTeamMenuState[index] = {
|
||||
...newOtherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
};
|
||||
setOtherTeamMenuState(newOtherTeamMenuState);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const newOtherTeamMenuState = [...otherTeamMenuState];
|
||||
newOtherTeamMenuState[index] = {
|
||||
...newOtherTeamMenuState[index],
|
||||
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
|
||||
}),
|
||||
])
|
||||
}>
|
||||
};
|
||||
setOtherTeamMenuState(newOtherTeamMenuState);
|
||||
}
|
||||
}}
|
||||
aria-label={`${otherTeam.name} ${
|
||||
otherTeamMenuState[index].teamMenuOpen
|
||||
? t("collapse_menu")
|
||||
: t("expand_menu")
|
||||
}`}>
|
||||
<div className="me-3">
|
||||
{otherTeamMenuState[index].teamMenuOpen ? (
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
@@ -638,9 +666,11 @@ const SettingsSidebarContainer = ({
|
||||
/>
|
||||
)}
|
||||
<p className="w-1/2 truncate leading-normal">{otherTeam.name}</p>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-0.5">
|
||||
<CollapsibleContent
|
||||
className="space-y-0.5"
|
||||
id={`other-team-content-${otherTeam.id}`}>
|
||||
<VerticalTabItem
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateWebhooksList() {
|
||||
revalidatePath("/settings/developer/webhooks");
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { SkeletonLoader } from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<SettingsHeader
|
||||
title="Webhooks"
|
||||
description="Receive meeting data in real-time when something happens in Cal.com"
|
||||
borderInShellHeader={true}
|
||||
CTA={null}>
|
||||
<SkeletonLoader />
|
||||
</SettingsHeader>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { CreateNewWebhookButton } from "@calcom/features/webhooks/components";
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("webhooks"),
|
||||
(t) => t("add_webhook_description", { appName: APP_NAME })
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
const caller = await createRouterCaller(webhookRouter);
|
||||
const data = await caller.getByViewer();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("webhooks")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
CTA={data && data.webhookGroups.length > 0 ? <CreateNewWebhookButton isAdmin={isAdmin} /> : null}
|
||||
borderInShellHeader={(data && data.profiles.length === 1) || !data?.webhookGroups?.length}>
|
||||
<WebhooksView data={data} isAdmin={isAdmin} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhooksViewServerWrapper;
|
||||
+1
-13
@@ -2,9 +2,8 @@ import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { NewWebhookView, SkeletonLoader } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -12,17 +11,6 @@ export const generateMetadata = async () =>
|
||||
(t) => t("add_webhook_description", { appName: APP_NAME })
|
||||
);
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("webhooks"),
|
||||
(t) => t("add_webhook_description", { appName: APP_NAME })
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
return <WebhooksView />;
|
||||
};
|
||||
|
||||
export default WebhooksViewServerWrapper;
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type AppRouterI18nContextType = {
|
||||
translations: Record<string, string>;
|
||||
ns: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const AppRouterI18nContext = createContext<AppRouterI18nContextType | null>(null);
|
||||
|
||||
export function AppRouterI18nProvider({
|
||||
children,
|
||||
translations,
|
||||
locale,
|
||||
ns,
|
||||
}: AppRouterI18nContextType & {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Memoize the value to prevent re-renders unless the data changes
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
translations,
|
||||
locale,
|
||||
ns,
|
||||
}),
|
||||
[locale, ns]
|
||||
);
|
||||
|
||||
return <AppRouterI18nContext.Provider value={value}>{children}</AppRouterI18nContext.Provider>;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AVATAR_FALLBACK, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { convertSvgToPng } from "@calcom/lib/server/imageUtils";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -44,7 +45,19 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
img = data;
|
||||
|
||||
// Convert SVG to PNG if needed and update the database
|
||||
if (data.startsWith("data:image/svg+xml;base64,")) {
|
||||
const pngData = await convertSvgToPng(data);
|
||||
|
||||
await prisma.avatar.update({
|
||||
where: { objectKey },
|
||||
data: { data: pngData },
|
||||
});
|
||||
img = pngData;
|
||||
} else {
|
||||
img = data;
|
||||
}
|
||||
} catch (e) {
|
||||
// If anything goes wrong or avatar is not found, use default avatar
|
||||
const url = new URL(AVATAR_FALLBACK, WEBAPP_URL).toString();
|
||||
|
||||
@@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -83,7 +84,8 @@ async function handler(request: NextRequest) {
|
||||
const res = {} as any; // Response is still mocked as it's not used in this context
|
||||
|
||||
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
|
||||
const caller = bookingsRouter.createCaller({
|
||||
const createCaller = createCallerFactory(bookingsRouter);
|
||||
const caller = createCaller({
|
||||
...ctx,
|
||||
req: legacyReq,
|
||||
res,
|
||||
|
||||
@@ -7,8 +7,6 @@ import { performance } from "@calcom/lib/server/perfObserver";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
let isCold = true;
|
||||
|
||||
async function getHandler() {
|
||||
const prePrismaDate = performance.now();
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
@@ -30,7 +28,6 @@ async function getHandler() {
|
||||
|
||||
const lastUpdate = performance.now();
|
||||
|
||||
// Create response with headers
|
||||
const response = NextResponse.json({
|
||||
message: `Hello ${user.name}`,
|
||||
prePrismaDate,
|
||||
@@ -40,13 +37,8 @@ async function getHandler() {
|
||||
preUserDate,
|
||||
userDuration: `User took ${lastUpdate - preUserDate}ms`,
|
||||
lastUpdate,
|
||||
wasCold: isCold,
|
||||
});
|
||||
|
||||
// Set custom header
|
||||
response.headers.set("x-is-cold", isCold.toString());
|
||||
isCold = false;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ErrorProps = {
|
||||
reset?: () => void;
|
||||
};
|
||||
|
||||
export default function Error({ error }: ErrorProps) {
|
||||
export default function Error({ error, reset }: ErrorProps) {
|
||||
React.useEffect(() => {
|
||||
log.error(error);
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function Error({ error }: ErrorProps) {
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
reset={reset}
|
||||
statusCode={processedError.statusCode}
|
||||
error={processedError}
|
||||
message={processedError.message}
|
||||
|
||||
+17
-3
@@ -5,10 +5,12 @@ import { headers, cookies } from "next/headers";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { IconSprites } from "@calcom/ui/components/icon";
|
||||
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
|
||||
|
||||
import "../styles/globals.css";
|
||||
import { AppRouterI18nProvider } from "./AppRouterI18nProvider";
|
||||
import { SpeculationRules } from "./SpeculationRules";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
@@ -83,10 +85,15 @@ const getInitialProps = async (url: string) => {
|
||||
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
||||
|
||||
const req = { headers: await headers(), cookies: await cookies() };
|
||||
const newLocale = await getLocale(req);
|
||||
const newLocale = (await getLocale(req)) ?? "en";
|
||||
const direction = dir(newLocale);
|
||||
|
||||
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
||||
return {
|
||||
isEmbed,
|
||||
embedColorScheme,
|
||||
locale: newLocale,
|
||||
direction,
|
||||
};
|
||||
};
|
||||
|
||||
const getFallbackProps = () => ({
|
||||
@@ -107,6 +114,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const { locale, direction, isEmbed, embedColorScheme } = isSSG
|
||||
? getFallbackProps()
|
||||
: await getInitialProps(fullUrl);
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(locale, ns);
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -153,7 +162,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
"/insights",
|
||||
]}
|
||||
/>
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
<Providers>
|
||||
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
{children}
|
||||
</AppRouterI18nProvider>
|
||||
</Providers>
|
||||
{!isEmbed && <NotificationSoundHandler />}
|
||||
<NotificationSoundHandler />
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { SSRConfig } from "next-i18next/dist/types/types";
|
||||
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
|
||||
@@ -11,12 +9,10 @@ import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequir
|
||||
import AppProviders from "@lib/app-providers-app-dir";
|
||||
|
||||
export type PageWrapperProps = Readonly<{
|
||||
getLayout?: ((page: React.ReactElement) => React.ReactNode) | null;
|
||||
children: React.ReactNode;
|
||||
requiresLicense: boolean;
|
||||
nonce: string | undefined;
|
||||
isBookingPage?: boolean;
|
||||
i18n?: SSRConfig;
|
||||
}>;
|
||||
|
||||
function PageWrapper(props: PageWrapperProps) {
|
||||
@@ -41,25 +37,26 @@ function PageWrapper(props: PageWrapperProps) {
|
||||
nonce,
|
||||
};
|
||||
|
||||
const getLayout: (page: React.ReactElement) => React.ReactNode = props.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
||||
// And we don't want it to error here anyways
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
{getLayout(
|
||||
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : <>{props.children}</>
|
||||
)}
|
||||
</>
|
||||
</AppProviders>
|
||||
<>
|
||||
<AppProviders {...providerProps}>
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
||||
// And we don't want it to error here anyways
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
{props.requiresLicense ? (
|
||||
<LicenseRequired>{props.children}</LicenseRequired>
|
||||
) : (
|
||||
<>{props.children}</>
|
||||
)}
|
||||
</>
|
||||
</AppProviders>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
@@ -386,10 +387,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const bookingYear = dayjs(booking.startTime).year();
|
||||
const currentYear = dayjs().year();
|
||||
const isDifferentYear = bookingYear !== currentYear;
|
||||
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
@@ -730,6 +735,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
recurringDates={recurringDates}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -750,12 +756,14 @@ const BookingItemBadges = ({
|
||||
recurringDates,
|
||||
userTimeFormat,
|
||||
userTimeZone,
|
||||
isRescheduled,
|
||||
}: {
|
||||
booking: BookingItemProps;
|
||||
isPending: boolean;
|
||||
recurringDates: Date[] | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
isRescheduled: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -766,6 +774,13 @@ const BookingItemBadges = ({
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
|
||||
@@ -7,8 +7,11 @@ import { ErrorPage } from "./error-page";
|
||||
export default function BookingPageErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error }) => <ErrorPage error={error} message={`${error}`} displayDebug={true} />}
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<ErrorPage reset={resetErrorBoundary} error={error} message={`${error}`} displayDebug={true} />
|
||||
)}
|
||||
onError={(error, info) => {
|
||||
console.error(error);
|
||||
captureReactException(error, info);
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
/** Display debugging information */
|
||||
displayDebug?: boolean;
|
||||
children?: never;
|
||||
reset?: () => void;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
@@ -53,7 +54,10 @@ const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (
|
||||
|
||||
export const ErrorPage: React.FC<Props> = (props) => {
|
||||
const { message, statusCode, error, displayDebug } = { ...defaultProps, ...props };
|
||||
|
||||
const handleReset = () => {
|
||||
window.location.reload();
|
||||
props.reset?.();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="bg-subtle flex h-screen">
|
||||
@@ -77,8 +81,8 @@ export const ErrorPage: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
|
||||
<Button href="mailto:support@cal.com">Contact Support</Button>
|
||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||
Go back
|
||||
<Button color="secondary" className="ml-2" onClick={handleReset}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Card } from "@calcom/ui/components/card";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { helpCards } from "@lib/settings/platform/utils";
|
||||
|
||||
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
|
||||
|
||||
export const HelpCards = () => {
|
||||
const { t } = useLocale();
|
||||
const { userBillingData } = useGetUserAttributes();
|
||||
const isPaidUser = userBillingData?.valid && userBillingData?.plan?.toLowerCase() !== "free";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid-col-1 mb-4 grid gap-2 md:grid-cols-3">
|
||||
{helpCards.map((card) => {
|
||||
if (card.title === "Contact us" && !isPaidUser) return null;
|
||||
|
||||
const title = card.title === "Report issue" ? t("report_issue") : card.title;
|
||||
const description =
|
||||
card.title === "Report issue" ? t("report_issue_description") : card.description;
|
||||
const actionButtonChild = card.title === "Report issue" ? t("open_issue") : card.actionButton.child;
|
||||
|
||||
return (
|
||||
<div key={card.title}>
|
||||
<Card
|
||||
icon={<Icon name={card.icon} className="h-5 w-5 text-green-700" />}
|
||||
variant={card.variant}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
title={title}
|
||||
description={description}
|
||||
actionButton={{
|
||||
href: `${card.actionButton.href}`,
|
||||
child: `${card.actionButton.child}`,
|
||||
child: actionButtonChild,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
type PlatformBillingCardProps = {
|
||||
@@ -19,6 +20,7 @@ export const PlatformBillingCard = ({
|
||||
handleSubscribe,
|
||||
currentPlan,
|
||||
}: PlatformBillingCardProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="border-subtle max-w-[450px] rounded-2xl border p-5 md:mx-4">
|
||||
<div className="pb-5">
|
||||
@@ -30,7 +32,7 @@ export const PlatformBillingCard = ({
|
||||
type="button"
|
||||
StartIcon="circle-check"
|
||||
className="bg-default hover:bg-default cursor-none text-green-500 hover:cursor-pointer"
|
||||
tooltip="This is your current plan"
|
||||
tooltip={t("this_is_your_current_plan")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -39,7 +41,7 @@ export const PlatformBillingCard = ({
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{pricing !== undefined && (
|
||||
<>
|
||||
US${pricing} <span className="text-sm">per month</span>
|
||||
US${pricing} <span className="text-sm">{t("per_month")}</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
@@ -50,12 +52,12 @@ export const PlatformBillingCard = ({
|
||||
loading={isLoading}
|
||||
onClick={handleSubscribe}
|
||||
className="flex w-[100%] items-center justify-center">
|
||||
{pricing !== undefined ? "Subscribe" : "Schedule a time"}
|
||||
{pricing !== undefined ? t("subscribe") : t("schedule_a_time")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5">
|
||||
<p>This includes:</p>
|
||||
<p>{t("this_includes")}</p>
|
||||
{includes.map((feature) => {
|
||||
return (
|
||||
<div key={feature} className="my-2 flex">
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import type { SSRConfig } from "next-i18next/dist/types/types";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
@@ -23,17 +18,10 @@ import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
|
||||
|
||||
import { getThemeProviderProps } from "./getThemeProviderProps";
|
||||
|
||||
const I18nextAdapter = appWithTranslation<
|
||||
NextJsAppProps<SSRConfig> & {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
>(({ children }) => <>{children}</>);
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
@@ -41,7 +29,6 @@ export type AppProps = Omit<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
i18n?: SSRConfig;
|
||||
}>
|
||||
>
|
||||
>,
|
||||
@@ -50,7 +37,6 @@ export type AppProps = Omit<
|
||||
Component: NextAppProps["Component"] & {
|
||||
requiresLicense?: boolean;
|
||||
isBookingPage?: boolean | ((arg: { router: NextAppProps["router"] }) => boolean);
|
||||
getLayout?: (page: React.ReactElement) => ReactNode;
|
||||
PageWrapper?: (props: AppProps) => JSX.Element;
|
||||
};
|
||||
|
||||
@@ -64,57 +50,6 @@ const getEmbedNamespace = (searchParams: ReadonlyURLSearchParams) => {
|
||||
return typeof window !== "undefined" ? window.getEmbedNamespace() : searchParams.get("embed") ?? null;
|
||||
};
|
||||
|
||||
const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSRConfig }) => {
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
|
||||
const session = useSession();
|
||||
const fallbackLocale =
|
||||
typeof window !== "undefined" && window.document.documentElement.lang
|
||||
? window.document.documentElement.lang
|
||||
: "en";
|
||||
const newLocale = typeof window !== "undefined" && window.calNewLocale ? window.calNewLocale : null;
|
||||
const locale = session?.data?.user.locale ?? newLocale ?? fallbackLocale;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
|
||||
delete window.document.documentElement["lang"];
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
|
||||
// Next.js writes the locale to the same attribute
|
||||
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
|
||||
// which can result in a race condition
|
||||
// this property descriptor ensures this never happens
|
||||
Object.defineProperty(window.document.documentElement, "lang", {
|
||||
configurable: true,
|
||||
// value: locale,
|
||||
set: function (this) {
|
||||
// empty setter on purpose
|
||||
},
|
||||
get: function () {
|
||||
return locale;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
}
|
||||
window.document.dir = dir(locale);
|
||||
}, [locale]);
|
||||
|
||||
const clientViewerI18n = useViewerI18n(locale);
|
||||
const i18n = clientViewerI18n.data?.i18n ?? props.i18n;
|
||||
|
||||
return (
|
||||
// @ts-expect-error AppWithTranslationHoc expects AppProps
|
||||
<I18nextAdapter pageProps={{ _nextI18Next: i18n?._nextI18Next }}>{props.children}</I18nextAdapter>
|
||||
);
|
||||
};
|
||||
|
||||
type CalcomThemeProps = Readonly<{
|
||||
isBookingPage: boolean;
|
||||
nonce: string | undefined;
|
||||
@@ -177,8 +112,8 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
const isThemeSupported = useIsThemeSupported();
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<CustomI18nextProvider i18n={props.i18n}>
|
||||
<>
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
@@ -190,8 +125,8 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</EventCollectionProvider>
|
||||
</EventCollectionProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
if (props.isBookingPage || isBookingPage) {
|
||||
|
||||
@@ -24,17 +24,13 @@ const getUser = async (userId: number) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
let teams = userAdminTeams.teams.map(({ team }) => ({
|
||||
const teams = userAdminTeams.teams.map(({ team }) => ({
|
||||
...team,
|
||||
logoUrl: team.parent
|
||||
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
|
||||
: getPlaceholderAvatar(team.logoUrl, team.name),
|
||||
}));
|
||||
|
||||
const orgTeam = teams.find((team) => team.isOrganization === true);
|
||||
if (orgTeam?.id) {
|
||||
teams = teams.filter((team) => team?.parent?.id !== orgTeam.id);
|
||||
}
|
||||
return {
|
||||
...userAdminTeams,
|
||||
teams,
|
||||
|
||||
@@ -104,4 +104,15 @@ export const helpCards: HelpCardInfo[] = [
|
||||
child: "Schedule a call",
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "triangle-alert",
|
||||
title: "Report issue",
|
||||
description:
|
||||
"You can submit a ticket on GitHub or upgrade your plan to receive real-time support with developer conferences",
|
||||
variant: "basic",
|
||||
actionButton: {
|
||||
href: "https://github.com/calcom/cal.com/issues/new?template=platform.md",
|
||||
child: "Open Issue",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -18,7 +18,10 @@ const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
|
||||
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/"]; // trailing slash in "/api/" is actually important to block edge cases like `/api.php`
|
||||
// Some app routes are allowed because "revalidatePath()" is used to revalidate the cache for them
|
||||
export const POST_METHODS_ALLOWED_APP_ROUTES = ["/settings/my-account/general"];
|
||||
export const POST_METHODS_ALLOWED_APP_ROUTES = [
|
||||
"/settings/my-account/general",
|
||||
"/settings/developer/webhooks",
|
||||
];
|
||||
|
||||
export function checkPostMethod(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Apps({ apps, category }: CategoryDataProps) {
|
||||
</Link>
|
||||
{category && (
|
||||
<span className="text-default gap-1">
|
||||
<span> / </span>
|
||||
<span>{t("slash_separator")}</span>
|
||||
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -8,16 +8,17 @@ import z from "zod";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
async function sendVerificationLogin(email: string, username: string) {
|
||||
async function sendVerificationLogin(email: string, username: string, t: (key: string) => string) {
|
||||
await signIn("email", {
|
||||
email: email.toLowerCase(),
|
||||
username: username.toLowerCase(),
|
||||
@@ -25,7 +26,7 @@ async function sendVerificationLogin(email: string, username: string) {
|
||||
callbackUrl: WEBAPP_URL || "https://app.cal.com",
|
||||
})
|
||||
.then(() => {
|
||||
showToast("Verification email sent", "success");
|
||||
showToast(t("verification_email_sent"), "success");
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast(err, "error");
|
||||
@@ -40,15 +41,16 @@ function useSendFirstVerificationLogin({
|
||||
username: string | undefined;
|
||||
}) {
|
||||
const sent = useRef(false);
|
||||
const { t } = useLocale();
|
||||
useEffect(() => {
|
||||
if (!email || !username || sent.current) {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
await sendVerificationLogin(email, username);
|
||||
await sendVerificationLogin(email, username, t);
|
||||
sent.current = true;
|
||||
})();
|
||||
}, [email, username]);
|
||||
}, [email, username, t]);
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -117,7 +119,8 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||
const { t: tParam, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||
const { t } = useLocale();
|
||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
|
||||
{
|
||||
@@ -166,7 +169,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
}
|
||||
|
||||
if (!stripeCustomerId && !sessionId) {
|
||||
return <div>Invalid Link</div>;
|
||||
return <div>{t("invalid_link")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -176,18 +179,15 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
{hasPaymentFailed ? <PaymentFailedIcon /> : sessionId ? <PaymentSuccess /> : <MailOpenIcon />}
|
||||
<h3 className="font-cal text-emphasis my-6 text-2xl font-normal leading-none">
|
||||
{hasPaymentFailed
|
||||
? "Your payment failed"
|
||||
? t("your_payment_failed")
|
||||
: sessionId
|
||||
? "Payment successful!"
|
||||
: "Check your Inbox"}
|
||||
? t("payment_successful")
|
||||
: t("check_your_inbox")}
|
||||
</h3>
|
||||
{hasPaymentFailed && (
|
||||
<p className="my-6">Your account has been created, but your premium has not been reserved.</p>
|
||||
)}
|
||||
{hasPaymentFailed && <p className="my-6">{t("account_created_premium_not_reserved")}</p>}
|
||||
<p className="text-muted dark:text-subtle text-base font-normal">
|
||||
We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
|
||||
{hasPaymentFailed &&
|
||||
"Once you activate your account you will be able to try purchase your premium username again or select a different one."}
|
||||
{t("email_sent_with_activation_link", { email: customer?.email })}{" "}
|
||||
{hasPaymentFailed && t("activate_account_to_purchase_username")}
|
||||
</p>
|
||||
<div className="mt-7">
|
||||
<Button
|
||||
@@ -199,12 +199,12 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
}
|
||||
target="_blank"
|
||||
EndIcon="external-link">
|
||||
Open in Gmail
|
||||
{t("open_in_gmail")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-subtle text-base font-normal ">Don’t seen an email?</p>
|
||||
<p className="text-subtle text-base font-normal ">{t("dont_see_email")}</p>
|
||||
<button
|
||||
className={classNames(
|
||||
"font-light",
|
||||
@@ -221,9 +221,9 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
const _searchParams = new URLSearchParams(searchParams?.toString());
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
return await sendVerificationLogin(customer.email, customer.username, t);
|
||||
}}>
|
||||
{secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Resend"}
|
||||
{secondsLeft > 0 ? t("resend_in_seconds", { seconds: secondsLeft }) : t("resend")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +218,7 @@ export const AvailabilityCTA = () => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<ToggleGroup
|
||||
className="hidden md:block"
|
||||
className="min-h-10 hidden md:flex md:items-center"
|
||||
defaultValue={searchParams?.get("type") ?? "mine"}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ZDateRangeFilterValue,
|
||||
ZTextFilterValue,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -86,7 +87,7 @@ type BookingsProps = {
|
||||
|
||||
export default function Bookings(props: BookingsProps) {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<DataTableProvider useSegments={useSegments}>
|
||||
<BookingsContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -82,6 +82,17 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
rescheduledToUid = rescheduledTo?.uid ?? null;
|
||||
}
|
||||
|
||||
let previousBooking: {
|
||||
rescheduledBy: string | null;
|
||||
uid: string;
|
||||
} | null = null;
|
||||
|
||||
if (bookingInfo.fromReschedule) {
|
||||
previousBooking = await BookingRepository.findReschedulerByUid({
|
||||
uid: bookingInfo.fromReschedule,
|
||||
});
|
||||
}
|
||||
|
||||
const eventTypeRaw = !bookingInfoRaw.eventTypeId
|
||||
? getDefaultEvent(eventTypeSlug || "")
|
||||
: await getEventTypesFromDB(bookingInfoRaw.eventTypeId);
|
||||
@@ -118,7 +129,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
...eventTypeRaw,
|
||||
periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata),
|
||||
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventTypeRaw.metadata),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs),
|
||||
bookingFields: eventTypeRaw.bookingFields.map((field) => {
|
||||
@@ -220,6 +231,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||
bookingInfo,
|
||||
previousBooking,
|
||||
paymentStatus: payment,
|
||||
...(tz && { tz }),
|
||||
userTimeFormat,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { RefundPolicy } from "@calcom/lib/payment/types";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
@@ -104,7 +105,9 @@ export default function Success(props: PageProps) {
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { eventType, bookingInfo, requiresLoginToUpdate, rescheduledToUid } = props;
|
||||
|
||||
const { eventType, bookingInfo, previousBooking, requiresLoginToUpdate, rescheduledToUid } = props;
|
||||
|
||||
const {
|
||||
allRemainingBookings,
|
||||
isSuccessBookingPage,
|
||||
@@ -505,7 +508,37 @@ export default function Success(props: PageProps) {
|
||||
t("booking_with_payment_cancelled")}
|
||||
{props.paymentStatus.success &&
|
||||
!props.paymentStatus.refunded &&
|
||||
t("booking_with_payment_cancelled_already_paid")}
|
||||
(() => {
|
||||
const refundPolicy = eventType?.metadata?.apps?.stripe?.refundPolicy;
|
||||
const refundDaysCount = eventType?.metadata?.apps?.stripe?.refundDaysCount;
|
||||
|
||||
// Handle missing team or event type owner (same in processPaymentRefund.ts)
|
||||
if (!eventType?.teamId && !eventType?.owner) {
|
||||
return t("booking_with_payment_cancelled_no_refund");
|
||||
}
|
||||
|
||||
// Handle DAYS policy with expired refund window
|
||||
else if (refundPolicy === RefundPolicy.DAYS && refundDaysCount) {
|
||||
const startTime = new Date(bookingInfo.startTime);
|
||||
const cancelTime = new Date();
|
||||
const daysDiff = Math.floor(
|
||||
(cancelTime.getTime() - startTime.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff > refundDaysCount) {
|
||||
return t("booking_with_payment_cancelled_refund_window_expired");
|
||||
}
|
||||
}
|
||||
// Handle NEVER policy
|
||||
else if (refundPolicy === RefundPolicy.NEVER) {
|
||||
return t("booking_with_payment_cancelled_no_refund");
|
||||
}
|
||||
|
||||
// Handle ALWAYS policy
|
||||
else {
|
||||
return t("booking_with_payment_cancelled_already_paid");
|
||||
}
|
||||
})()}
|
||||
{props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")}
|
||||
</h4>
|
||||
)}
|
||||
@@ -519,6 +552,17 @@ export default function Success(props: PageProps) {
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
</>
|
||||
)}
|
||||
{previousBooking && (
|
||||
<>
|
||||
<div className="font-medium">{t("rescheduled_by")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<p className="break-words">{previousBooking?.rescheduledBy}</p>
|
||||
<Link className="text-sm underline" href={`/booking/${previousBooking?.uid}`}>
|
||||
{t("original_booking")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0" data-testid="booking-title">
|
||||
{isRoundRobin ? bookingInfo.title : eventName}
|
||||
|
||||
@@ -18,14 +18,12 @@ import {
|
||||
InfiniteSkeletonLoader,
|
||||
EventTypesSkeletonLoader,
|
||||
} from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { parseEventTypeColor } from "@calcom/lib";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -931,8 +929,8 @@ const InfiniteScrollMain = ({
|
||||
// This is to ensure that the bookerUrl is always the same as the one in the org branding settings
|
||||
// This keeps the app working for personal event types that were not migrated to the org (rare)
|
||||
if (
|
||||
activeEventTypeGroup[0].teamId === orgBranding?.id ||
|
||||
activeEventTypeGroup[0].parentId === orgBranding?.id
|
||||
orgBranding &&
|
||||
(activeEventTypeGroup[0].teamId === orgBranding.id || activeEventTypeGroup[0].parentId === orgBranding.id)
|
||||
) {
|
||||
activeEventTypeGroup[0].bookerUrl = bookerUrl;
|
||||
}
|
||||
@@ -948,11 +946,14 @@ const InfiniteScrollMain = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTypesCTA = () => {
|
||||
type Props = {
|
||||
initialData: RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"];
|
||||
filters?: { teamIds?: number[] | undefined; userIds?: number[] | undefined; upIds?: string[] | undefined };
|
||||
};
|
||||
|
||||
export const EventTypesCTA = ({ initialData, filters }: Props) => {
|
||||
const { data: user } = useMeQuery();
|
||||
const routerQuery = useRouterQuery();
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
const { data: getUserEventGroupsData } = trpc.viewer.eventTypes.getUserEventGroups.useQuery(
|
||||
const { data: userEventGroupsQuery } = trpc.viewer.eventTypes.getUserEventGroups.useQuery(
|
||||
filters && { filters },
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -960,6 +961,9 @@ export const EventTypesCTA = () => {
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
const getUserEventGroupsData = userEventGroupsQuery ?? initialData;
|
||||
|
||||
const profileOptions =
|
||||
getUserEventGroupsData?.profiles
|
||||
?.filter((profile) => !profile.readOnly)
|
||||
@@ -977,13 +981,11 @@ export const EventTypesCTA = () => {
|
||||
return <CTA profileOptions={profileOptions} isOrganization={!!user?.organizationId} />;
|
||||
};
|
||||
|
||||
const EventTypesPage: React.FC = () => {
|
||||
const EventTypesPage = ({ initialData, filters }: Props) => {
|
||||
const { data: user } = useMeQuery();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
const orgBranding = useOrgBranding();
|
||||
const routerQuery = useRouterQuery();
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
const router = useRouter();
|
||||
|
||||
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
|
||||
@@ -995,6 +997,7 @@ const EventTypesPage: React.FC = () => {
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 1 * 60 * 60 * 1000,
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
initialData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { DataTableProvider } from "@calcom/features/data-table/DataTableProvider";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import {
|
||||
RoutingFormResponsesTable,
|
||||
FailedBookingsByField,
|
||||
@@ -13,7 +14,7 @@ export default function InsightsRoutingFormResponsesPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<DataTableProvider useSegments={useSegments}>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<div className="mb-4 space-y-4">
|
||||
<RoutingFormResponsesTable />
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export default function MaintenancePage() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="bg-subtle flex h-screen">
|
||||
<div className="bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
||||
<h1 className="text-emphasis text-2xl font-medium">Down for maintenance</h1>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
|
||||
The Cal.com team are performing scheduled maintenance. If you have any questions, please contact
|
||||
support.
|
||||
</p>
|
||||
<Button href={`${WEBSITE_URL}/support`}>Contact Support</Button>
|
||||
<h1 className="text-emphasis text-2xl font-medium">{t("down_for_maintenance")}</h1>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("maintenance_message")}</p>
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SMSLockState } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -51,6 +52,7 @@ const LockStatusTable = ({
|
||||
teams?: Team[];
|
||||
setSMSLockState: (param: { userId?: number; teamId?: number; lock: boolean }) => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
function getActions({ user, team }: { user?: User; team?: Team }) {
|
||||
const smsLockState = user?.smsLockState ?? team?.smsLockState;
|
||||
if (!smsLockState) return [];
|
||||
@@ -89,10 +91,10 @@ const LockStatusTable = ({
|
||||
<>
|
||||
<Table>
|
||||
<Header>
|
||||
<ColumnTitle widthClassNames="w-auto">User/Team</ColumnTitle>
|
||||
<ColumnTitle>Status</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">{t("user_team")}</ColumnTitle>
|
||||
<ColumnTitle>{t("status")}</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">
|
||||
<span className="sr-only">Edit</span>
|
||||
<span className="sr-only">{t("edit")}</span>
|
||||
</ColumnTitle>
|
||||
</Header>
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function OAuthView() {
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-emphasis mb-5 text-xl font-semibold">{oAuthForm.getValues("name")}</div>
|
||||
<div className="mb-2 font-medium">Client Id</div>
|
||||
<div className="mb-2 font-medium">{t("client_id")}</div>
|
||||
<div className="flex">
|
||||
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
|
||||
{" "}
|
||||
@@ -105,7 +105,7 @@ export default function OAuthView() {
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(clientId);
|
||||
showToast("Client ID copied!", "success");
|
||||
showToast(t("client_id_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none text-base"
|
||||
@@ -116,7 +116,7 @@ export default function OAuthView() {
|
||||
</div>
|
||||
{clientSecret ? (
|
||||
<>
|
||||
<div className="mb-2 mt-4 font-medium">Client Secret</div>
|
||||
<div className="mb-2 mt-4 font-medium">{t("client_secret")}</div>
|
||||
<div className="flex">
|
||||
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
|
||||
{" "}
|
||||
@@ -127,7 +127,7 @@ export default function OAuthView() {
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(clientSecret);
|
||||
setClientSecret("");
|
||||
showToast("Client secret copied!", "success");
|
||||
showToast(t("client_secret_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-l-none text-base"
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function PlatformBillingUpgrade() {
|
||||
useGetUserAttributes();
|
||||
|
||||
if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) {
|
||||
return <div className="m-5">Loading...</div>;
|
||||
return <div className="m-5">{t("loading")}</div>;
|
||||
}
|
||||
|
||||
if (isPlatformUser && !isPaidUser)
|
||||
@@ -47,7 +47,7 @@ export default function PlatformBillingUpgrade() {
|
||||
teamId={userOrgId}
|
||||
heading={
|
||||
<div className="mb-5 text-center text-2xl font-semibold">
|
||||
<h1>Subscribe to Platform</h1>
|
||||
<h1>{t("subscribe_to_platform")}</h1>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function EditOAuthClient() {
|
||||
const { data, isFetched, isFetching, isError, refetch } = useOAuthClient(clientId);
|
||||
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client updated successfully", "success");
|
||||
showToast(t("oauth_client_updated_successfully"), "success");
|
||||
refetch();
|
||||
router.push("/settings/platform/");
|
||||
},
|
||||
@@ -63,7 +63,7 @@ export default function EditOAuthClient() {
|
||||
});
|
||||
};
|
||||
|
||||
if (isUserLoading) return <div className="m-5">Loading...</div>;
|
||||
if (isUserLoading) return <div className="m-5">{t("loading")}</div>;
|
||||
|
||||
if (isPlatformUser && isPaidUser) {
|
||||
return (
|
||||
@@ -80,7 +80,7 @@ export default function EditOAuthClient() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(!Boolean(clientId) || (isFetched && !data)) && <p>OAuth Client not found.</p>}
|
||||
{(!Boolean(clientId) || (isFetched && !data)) && <p>{t("oauth_client_not_found")}</p>}
|
||||
{isFetched && !!data && (
|
||||
<EditOAuthClientForm
|
||||
defaultValues={{
|
||||
@@ -106,8 +106,8 @@ export default function EditOAuthClient() {
|
||||
isPending={isUpdating}
|
||||
/>
|
||||
)}
|
||||
{isFetching && <p>Loading...</p>}
|
||||
{isError && <p>Something went wrong.</p>}
|
||||
{isFetching && <p>{t("loading")}</p>}
|
||||
{isError && <p>{t("something_went_wrong")}</p>}
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -37,7 +37,7 @@ export default function EditOAuthClientWebhooks() {
|
||||
const { mutateAsync: createWebhook } = useCreateOAuthClientWebhook(clientId);
|
||||
const { mutateAsync: updateWebhook } = useUpdateOAuthClientWebhook(clientId);
|
||||
|
||||
if (isUserLoading) return <div className="m-5">Loading...</div>;
|
||||
if (isUserLoading) return <div className="m-5">{t("loading")}</div>;
|
||||
|
||||
if (isPlatformUser && isPaidUser) {
|
||||
return (
|
||||
@@ -55,7 +55,7 @@ export default function EditOAuthClientWebhooks() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webhooksStatus !== "success" && <p>Error while trying to access webhooks.</p>}
|
||||
{webhooksStatus !== "success" && <p>{t("error_accessing_webhooks")}</p>}
|
||||
|
||||
{isWebhooksFetched && webhooksStatus === "success" && (
|
||||
<WebhookForm
|
||||
@@ -96,7 +96,7 @@ export default function EditOAuthClientWebhooks() {
|
||||
await refetchWebhooks();
|
||||
router.push("/settings/platform/");
|
||||
} catch (err) {
|
||||
showToast(`Failed to ${webhookId ? "update" : "create"} webhook.`, "error");
|
||||
showToast(t(webhookId ? "webhook_update_failed" : "webhook_create_failed"), "error");
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function CreateOAuthClient() {
|
||||
});
|
||||
};
|
||||
|
||||
if (isUserLoading) return <div className="m-5">Loading...</div>;
|
||||
if (isUserLoading) return <div className="m-5">{t("loading")}</div>;
|
||||
|
||||
if (isPlatformUser && isPaidUser) {
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import NoPlatformPlan from "@components/settings/platform/dashboard/NoPlatformPlan";
|
||||
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
|
||||
import { PlatformPricing } from "@components/settings/platform/pricing/platform-pricing";
|
||||
|
||||
export default function PlatformPlans() {
|
||||
const { t } = useLocale();
|
||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } =
|
||||
useGetUserAttributes();
|
||||
|
||||
if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) {
|
||||
return <div className="m-5">Loading...</div>;
|
||||
return <div className="m-5">{t("loading")}</div>;
|
||||
}
|
||||
|
||||
if (!isPlatformUser) return <NoPlatformPlan />;
|
||||
@@ -23,8 +25,10 @@ export default function PlatformPlans() {
|
||||
isPlatformUser={true}
|
||||
heading={
|
||||
<h1 className="mx-2 mt-4 text-center text-xl md:text-2xl">
|
||||
You are currently subscribed to {userBillingData?.plan[0]}
|
||||
{userBillingData?.plan.slice(1).toLocaleLowerCase()} plan
|
||||
{t("currently_subscribed_to_plan", {
|
||||
planFirstLetter: userBillingData?.plan[0],
|
||||
planRest: userBillingData?.plan.slice(1).toLocaleLowerCase(),
|
||||
})}
|
||||
</h1>
|
||||
}
|
||||
withoutMain={false}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user