Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899da51f06 | ||
|
|
4e085e404a | ||
|
|
ff3aaadbaa | ||
|
|
11bddbb71d | ||
|
|
55a80ba253 | ||
|
|
32a89c30de | ||
|
|
5404eeedc2 | ||
|
|
d54ba90304 | ||
|
|
ca206915bf | ||
|
|
8461b82f0d | ||
|
|
07ffc3fade | ||
|
|
c2240f0bad | ||
|
|
36a712f979 | ||
|
|
842ba09896 | ||
|
|
b3c822abf7 | ||
|
|
1f52ed7290 | ||
|
|
a2c13e576e | ||
|
|
24bb52d2e9 | ||
|
|
d8145bbc78 | ||
|
|
8753a6e4d6 | ||
|
|
b91607b5b4 | ||
|
|
af0e839f27 | ||
|
|
b6de03de96 | ||
|
|
e115095198 | ||
|
|
c3add4bae8 | ||
|
|
3040e14624 | ||
|
|
34993e3f36 | ||
|
|
8d5c6b8877 | ||
|
|
c9eac73fec | ||
|
|
ab475b80f3 | ||
|
|
2b79039b70 | ||
|
|
044df04d3f | ||
|
|
2cbf805ffb | ||
|
|
d208074063 | ||
|
|
30541da1fa | ||
|
|
97af0af0ba | ||
|
|
e9b26921da | ||
|
|
e4434f199d | ||
|
|
f4b0219a0e | ||
|
|
6f1eaaa02e | ||
|
|
e5868837d5 | ||
|
|
71a5d8b261 | ||
|
|
dfc022d890 | ||
|
|
68cb0cf197 | ||
|
|
14592de6c4 | ||
|
|
d41dcc69ad | ||
|
|
0a28b5825b | ||
|
|
3131318b6a | ||
|
|
08e35e4698 | ||
|
|
ed4b3f082a | ||
|
|
a1007398f6 | ||
|
|
2aafa1c163 | ||
|
|
008178480a | ||
|
|
2334c3bef8 | ||
|
|
9542116ec1 | ||
|
|
f9c11c2856 | ||
|
|
30060f03eb | ||
|
|
b8ffcef45e | ||
|
|
999882b795 | ||
|
|
bda963b851 | ||
|
|
8511494d68 | ||
|
|
1c3ad9dad6 | ||
|
|
d43afe7dbd | ||
|
|
a06b2009ab | ||
|
|
2a786b4652 | ||
|
|
ef1844557f | ||
|
|
d1f7b043e8 | ||
|
|
3a8e40d029 | ||
|
|
bca46228e7 | ||
|
|
d15b24b8ea | ||
|
|
d9df9b3d01 | ||
|
|
fc1d6ae402 | ||
|
|
26b4b7a868 | ||
|
|
a839630ca3 | ||
|
|
e21dbb704e | ||
|
|
53a06797a9 | ||
|
|
99eb743772 | ||
|
|
73adfb0cec | ||
|
|
7127c60116 | ||
|
|
a6d37fd167 | ||
|
|
16ea15076d | ||
|
|
041871f1b6 | ||
|
|
8c8ae127fe | ||
|
|
357c82129c | ||
|
|
324ac691eb | ||
|
|
da99001a7a | ||
|
|
bd10d07083 | ||
|
|
0e9b419a07 | ||
|
|
d5bd08d279 | ||
|
|
4cc83530f9 | ||
|
|
4fcf4081d0 | ||
|
|
d84d80eea6 | ||
|
|
f3e1d67de1 | ||
|
|
2ce25f54d4 | ||
|
|
25eac5d29b | ||
|
|
1710e30719 | ||
|
|
6d970d0b57 | ||
|
|
41a6035a12 | ||
|
|
f84c8239e7 | ||
|
|
1dca160db0 | ||
|
|
4ff366cd99 | ||
|
|
21a4711f07 | ||
|
|
f1dd217de7 | ||
|
|
3e8f0b125d | ||
|
|
25246ef8ff | ||
|
|
bb98143e2e | ||
|
|
6258ca14d0 | ||
|
|
d0e17e8611 | ||
|
|
79dc76239b | ||
|
|
95d62354d9 | ||
|
|
b9fadf82a7 | ||
|
|
1740e34c79 | ||
|
|
9e09066e99 | ||
|
|
0a9cfd3a08 | ||
|
|
0be711ad6e | ||
|
|
1c507b8169 | ||
|
|
81bdb3dc91 | ||
|
|
5efc7f3856 | ||
|
|
0afecec607 | ||
|
|
920574de65 |
@@ -188,6 +188,10 @@ TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
|
||||
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
|
||||
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
|
||||
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
|
||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
@@ -441,6 +445,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
|
||||
# Used to enable quick availability checks for x% of all visitors
|
||||
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
|
||||
# Used to generate token for cal video recordings expiry token
|
||||
# You can use: `openssl rand -base64 32` to generate one
|
||||
CAL_VIDEO_RECORDING_TOKEN_SECRET=
|
||||
|
||||
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Submodule Sync
|
||||
on:
|
||||
schedule:
|
||||
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
|
||||
- cron: "15 */4 * * *"
|
||||
workflow_dispatch: ~
|
||||
jobs:
|
||||
submodule-sync:
|
||||
name: Submodule update
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Commit
|
||||
run: |
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions"
|
||||
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
echo "Updating submodules recursively"
|
||||
pwd
|
||||
git submodule update --init --recursive
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+189
@@ -0,0 +1,189 @@
|
||||
# Cal.com Permission Documentation
|
||||
|
||||
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
|
||||
|
||||
## Permissions by Resource
|
||||
|
||||
### Team Permissions
|
||||
|
||||
| Permission String | Description | File Path | Line |
|
||||
|------------------|-------------|-----------|------|
|
||||
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
|
||||
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
|
||||
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
|
||||
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
|
||||
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
|
||||
|
||||
### Event Type Permissions
|
||||
|
||||
| Permission String | Description | File Path | Line |
|
||||
|------------------|-------------|-----------|------|
|
||||
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
|
||||
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
|
||||
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
|
||||
|
||||
### Booking Permissions
|
||||
|
||||
| Permission String | Description | File Path | Line |
|
||||
|------------------|-------------|-----------|------|
|
||||
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
|
||||
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||
|
||||
### Organization Permissions
|
||||
|
||||
| Permission String | Description | File Path | Line |
|
||||
|------------------|-------------|-----------|------|
|
||||
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
|
||||
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
|
||||
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
|
||||
|
||||
### API Key Permissions
|
||||
|
||||
| Permission String | Description | File Path | Line |
|
||||
|------------------|-------------|-----------|------|
|
||||
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
|
||||
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
|
||||
|
||||
## Helper Functions and Utilities
|
||||
|
||||
The following helper functions are commonly used for permission checks:
|
||||
|
||||
| Function | Description | File Path |
|
||||
|----------|-------------|-----------|
|
||||
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
|
||||
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
|
||||
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
|
||||
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
|
||||
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
|
||||
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
|
||||
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
|
||||
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
|
||||
|
||||
## Permission Level Enums
|
||||
|
||||
The codebase uses several enums to define permission levels:
|
||||
|
||||
### MembershipRole Enum
|
||||
|
||||
```typescript
|
||||
enum MembershipRole {
|
||||
OWNER = "OWNER",
|
||||
ADMIN = "ADMIN",
|
||||
MEMBER = "MEMBER"
|
||||
}
|
||||
```
|
||||
|
||||
### Entity Permission Level Enum
|
||||
|
||||
```typescript
|
||||
enum ENTITY_PERMISSION_LEVEL {
|
||||
NONE,
|
||||
USER_ONLY_WRITE,
|
||||
TEAM_READ_ONLY,
|
||||
TEAM_WRITE
|
||||
}
|
||||
```
|
||||
|
||||
## Common Permission Patterns
|
||||
|
||||
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
|
||||
```typescript
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
```
|
||||
|
||||
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
|
||||
```typescript
|
||||
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
```
|
||||
|
||||
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
|
||||
```typescript
|
||||
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
|
||||
}
|
||||
```
|
||||
|
||||
4. **Entity Permission Check**: Entity operations use permission level checks.
|
||||
```typescript
|
||||
const permissionLevel = await getEntityPermissionLevel(entity, userId);
|
||||
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
|
||||
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
|
||||
```
|
||||
|
||||
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
|
||||
```typescript
|
||||
const membershipIdsWhereUserIsAdminOwner = (
|
||||
await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
).map((membership) => membership.id);
|
||||
```
|
||||
|
||||
## Migration to PBAC
|
||||
|
||||
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
|
||||
|
||||
```typescript
|
||||
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
|
||||
|
||||
// Instead of:
|
||||
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// Use:
|
||||
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
```
|
||||
|
||||
## Permission String Alternatives for Helper Functions
|
||||
|
||||
The following table provides permission string alternatives for common helper functions:
|
||||
|
||||
| Helper Function | Permission String Alternative | Description |
|
||||
|-----------------|-------------------------------|-------------|
|
||||
| isTeamAdmin | `team.*` | Grants all team permissions |
|
||||
| isTeamAdmin | `team.update` | Update team settings |
|
||||
| isTeamAdmin | `team.invite` | Invite team members |
|
||||
| isTeamAdmin | `team.remove` | Remove team members |
|
||||
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
|
||||
| isTeamOwner | `team.delete` | Delete team |
|
||||
| isTeamMember | `team.read` | Read-only access to team |
|
||||
|
||||
## Entity Permission Functions and Resources
|
||||
|
||||
The following functions are used to check permissions for entities with userId and teamId properties:
|
||||
|
||||
| Function | Description | Permission String Alternative |
|
||||
|----------|-------------|-------------------------------|
|
||||
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
|
||||
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
|
||||
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
|
||||
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
|
||||
|
||||
### Resources Used with Entity Permission Functions
|
||||
|
||||
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|
||||
|----------|-------------------|---------------------|------------------|
|
||||
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
|
||||
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
|
||||
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
|
||||
|
||||
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
|
||||
|
||||
```typescript
|
||||
// Instead of:
|
||||
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
// Use:
|
||||
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
```
|
||||
|
||||
This allows for more granular permission control and the creation of custom roles with specific permissions.
|
||||
+11
-6
@@ -1,4 +1,4 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
@@ -7,12 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
|
||||
|
||||
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
@@ -21,11 +21,16 @@ afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Verify API key", () => {
|
||||
const mockDeploymentRepository: IDeploymentRepository = {
|
||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
||||
};
|
||||
|
||||
// TODO: Fix the skip condition for this test suite
|
||||
describe.skip("Verify API key", () => {
|
||||
let service: ILicenseKeyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = await LicenseKeyService.create();
|
||||
service = await LicenseKeyService.create(mockDeploymentRepository);
|
||||
|
||||
vi.spyOn(service, "checkLicense");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
@@ -19,11 +20,12 @@ export const dateNotInPast = function (date: Date) {
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const licenseKeyService = await LicenseKeySingleton.getInstance();
|
||||
const deploymentRepo = new DeploymentRepository(prisma);
|
||||
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
|
||||
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||
|
||||
if (!hasValidLicense && IS_PRODUCTION) {
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
return res.status(401).json({ message: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
}
|
||||
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
|
||||
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
|
||||
id: true,
|
||||
// No eventTypeId support in API v1
|
||||
eventTypeId: true,
|
||||
|
||||
/** No watch related fields support in API v1 */
|
||||
watchAttempts: true,
|
||||
unwatchAttempts: true,
|
||||
maxAttempts: true,
|
||||
});
|
||||
|
||||
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^4.9.4",
|
||||
"typescript": "^5.8.3",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -38,7 +38,8 @@ describe("GET /api/bookings", async () => {
|
||||
|
||||
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
|
||||
expect(groupedUsers.size).toBe(1);
|
||||
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
|
||||
const firstEntry = groupedUsers.entries().next().value;
|
||||
expect(firstEntry?.[0]).toBe(memberUser.id);
|
||||
});
|
||||
|
||||
it("Returns bookings for regular user", async () => {
|
||||
|
||||
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
|
||||
googleChannelResourceUri: null,
|
||||
googleChannelExpiration: null,
|
||||
error: null,
|
||||
lastErrorAt: null,
|
||||
watchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
unwatchAttempts: 0,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
|
||||
domainWideDelegationCredentialId: null,
|
||||
eventTypeId: null,
|
||||
error: null,
|
||||
lastErrorAt: null,
|
||||
watchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
unwatchAttempts: 0,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.8.2"
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -290,7 +290,8 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to auto-selected host",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
description:
|
||||
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -311,7 +312,8 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to a specific host",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
description:
|
||||
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
|
||||
@@ -2399,6 +2399,146 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't reschedule cancelled booking", () => {
|
||||
it("should not be able to reschedule cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
|
||||
it("should not be able to reschedule rescheduled booking", async () => {
|
||||
const rescheduledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
rescheduled: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newBooking = await bookingsRepositoryFixture.create({
|
||||
status: "ACCEPTED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fromReschedule: rescheduledBooking.uid,
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `new-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: RescheduleBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
|
||||
reschedulingReason: "Flying to mars that day",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
|
||||
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { getPagination } from "@/lib/pagination/pagination";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
@@ -108,6 +109,12 @@ export class BookingsService_2024_08_13 {
|
||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||
}
|
||||
|
||||
if (eventType.schedulingType === "MANAGED") {
|
||||
throw new BadRequestException(
|
||||
`Event type with id=${eventType.id} is the parent managed event type that can't be booked. You have to provide the child event type id aka id of event type that has been assigned to one of the users.`
|
||||
);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
|
||||
if ("instant" in body && body.instant) {
|
||||
@@ -532,10 +539,13 @@ export class BookingsService_2024_08_13 {
|
||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||
}
|
||||
|
||||
const skip = Math.abs(queryParams?.skip ?? 0);
|
||||
const take = Math.abs(queryParams?.take ?? 100);
|
||||
|
||||
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
|
||||
bookingListingByStatus: queryParams.status || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
take: queryParams.take ?? 100,
|
||||
skip,
|
||||
take,
|
||||
filters: {
|
||||
...this.inputService.transformGetBookingsFilters(queryParams),
|
||||
...(userIds?.length ? { userIds } : {}),
|
||||
@@ -588,28 +598,11 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
const skip = Math.abs(queryParams?.skip ?? 0);
|
||||
const take = Math.abs(queryParams?.take ?? 100);
|
||||
const itemsPerPage = take;
|
||||
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
|
||||
const currentPage = Math.floor(skip / itemsPerPage) + 1;
|
||||
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
|
||||
const hasPreviousPage = skip > 0;
|
||||
const pagination = getPagination({ skip, take, totalCount: fetchedBookings.totalCount });
|
||||
|
||||
return {
|
||||
bookings: formattedBookings,
|
||||
pagination: {
|
||||
totalItems: fetchedBookings.totalCount,
|
||||
// clamp remainingItems between 0 and totalCount
|
||||
remainingItems: Math.min(
|
||||
Math.max(fetchedBookings.totalCount - (skip + take), 0),
|
||||
fetchedBookings.totalCount
|
||||
),
|
||||
itemsPerPage: itemsPerPage,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
hasNextPage: hasNextPage,
|
||||
hasPreviousPage: hasPreviousPage,
|
||||
},
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -644,6 +637,7 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||
try {
|
||||
await this.canRescheduleBooking(bookingUid);
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
request,
|
||||
bookingUid,
|
||||
@@ -689,15 +683,29 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
this.errorsBookingsService.handleBookingError(error, false);
|
||||
}
|
||||
}
|
||||
|
||||
async canRescheduleBooking(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
if (booking.status === "CANCELLED" && !booking.rescheduled) {
|
||||
throw new BadRequestException(
|
||||
`Can't reschedule booking with uid=${bookingUid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
if (booking.status === "CANCELLED" && booking.rescheduled) {
|
||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||
throw new BadRequestException(
|
||||
`Can't reschedule booking with uid=${bookingUid} because it has been cancelled and rescheduled already to booking with uid=${rescheduledTo?.uid}. You probably want to reschedule ${rescheduledTo?.uid} instead by passing it within the request URL.`
|
||||
);
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
|
||||
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
|
||||
if (this.inputService.isCancelSeatedBody(body)) {
|
||||
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { getPagination, clamp } from "./pagination";
|
||||
|
||||
describe("getPagination", () => {
|
||||
it("handles the first page correctly", () => {
|
||||
const pagination = getPagination({ skip: 0, take: 10, totalCount: 35 });
|
||||
expect(pagination).toEqual({
|
||||
returnedItems: 10,
|
||||
totalItems: 35,
|
||||
itemsPerPage: 10,
|
||||
remainingItems: 25,
|
||||
currentPage: 1,
|
||||
totalPages: 4,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a middle page correctly", () => {
|
||||
const pagination = getPagination({ skip: 10, take: 10, totalCount: 35 });
|
||||
expect(pagination).toEqual({
|
||||
returnedItems: 10,
|
||||
totalItems: 35,
|
||||
itemsPerPage: 10,
|
||||
remainingItems: 15,
|
||||
currentPage: 2,
|
||||
totalPages: 4,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles the last page when it is not completely full", () => {
|
||||
const pagination = getPagination({ skip: 30, take: 10, totalCount: 35 });
|
||||
expect(pagination).toEqual({
|
||||
returnedItems: 5,
|
||||
totalItems: 35,
|
||||
itemsPerPage: 10,
|
||||
remainingItems: 0,
|
||||
currentPage: 4,
|
||||
totalPages: 4,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps skip values that exceed the total item count", () => {
|
||||
const pagination = getPagination({ skip: 40, take: 10, totalCount: 35 });
|
||||
expect(pagination).toEqual({
|
||||
returnedItems: 0,
|
||||
totalItems: 35,
|
||||
itemsPerPage: 10,
|
||||
remainingItems: 0,
|
||||
currentPage: 4,
|
||||
totalPages: 4,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("works when itemsPerPage (take) is zero", () => {
|
||||
const pagination = getPagination({ skip: 0, take: 0, totalCount: 35 });
|
||||
expect(pagination).toEqual({
|
||||
returnedItems: 0,
|
||||
totalItems: 35,
|
||||
itemsPerPage: 0,
|
||||
remainingItems: 35,
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("works when the collection is empty", () => {
|
||||
const pagination = getPagination({ skip: 0, take: 10, totalCount: 0 });
|
||||
expect(pagination).toEqual({
|
||||
returnedItems: 0,
|
||||
totalItems: 0,
|
||||
itemsPerPage: 10,
|
||||
remainingItems: 0,
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp", () => {
|
||||
it("returns the value unchanged when it is inside the range", () => {
|
||||
expect(clamp({ value: 5, min: 1, max: 10 })).toBe(5);
|
||||
});
|
||||
|
||||
it("clamps values below the minimum", () => {
|
||||
expect(clamp({ value: -3, min: 0, max: 10 })).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps values above the maximum", () => {
|
||||
expect(clamp({ value: 42, min: 0, max: 10 })).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { PaginationMetaDto } from "@calcom/platform-types";
|
||||
|
||||
type Pagination = {
|
||||
skip: number;
|
||||
take: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export function getPagination(pagination: Pagination): PaginationMetaDto {
|
||||
const { skip, take, totalCount } = pagination;
|
||||
|
||||
const safeSkip = clamp({ value: skip, min: 0, max: totalCount });
|
||||
const itemsPerPage = take;
|
||||
const remainingItems = getRemainingItems(safeSkip, itemsPerPage, totalCount);
|
||||
const totalPages = getTotalPages(itemsPerPage, totalCount);
|
||||
const currentPage = getCurrentPage(safeSkip, itemsPerPage, totalPages);
|
||||
const hasNextPage = getHasNextPage(safeSkip, itemsPerPage, totalCount);
|
||||
const hasPreviousPage = getHasPreviousPage(safeSkip);
|
||||
const returnedItems = getReturnedItems(safeSkip, itemsPerPage, totalCount);
|
||||
|
||||
return {
|
||||
returnedItems,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
remainingItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
};
|
||||
}
|
||||
|
||||
function getRemainingItems(skip: number, itemsPerPage: number, totalCount: number) {
|
||||
return clamp({
|
||||
value: totalCount - (skip + itemsPerPage),
|
||||
min: 0,
|
||||
max: totalCount,
|
||||
});
|
||||
}
|
||||
|
||||
function getTotalPages(itemsPerPage: number, totalCount: number) {
|
||||
return itemsPerPage !== 0 ? Math.ceil(totalCount / itemsPerPage) : 0;
|
||||
}
|
||||
|
||||
function getCurrentPage(skip: number, itemsPerPage: number, totalPages: number) {
|
||||
const rawCurrentPage = Math.floor(skip / itemsPerPage) + 1;
|
||||
if (totalPages === 0) {
|
||||
return 0;
|
||||
}
|
||||
return clamp({ value: rawCurrentPage, min: 1, max: totalPages });
|
||||
}
|
||||
|
||||
function getHasNextPage(skip: number, itemsPerPage: number, totalCount: number) {
|
||||
return skip + itemsPerPage < totalCount;
|
||||
}
|
||||
|
||||
function getHasPreviousPage(skip: number) {
|
||||
return skip > 0;
|
||||
}
|
||||
|
||||
function getReturnedItems(skip: number, itemsPerPage: number, totalCount: number) {
|
||||
const remaining = totalCount - skip;
|
||||
return clamp({
|
||||
value: remaining,
|
||||
min: 0,
|
||||
max: itemsPerPage,
|
||||
});
|
||||
}
|
||||
|
||||
type ClampArgs = {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export function clamp({ value, min, max }: ClampArgs): number {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
@@ -226,20 +226,26 @@ export class EventTypesAtomService {
|
||||
} else {
|
||||
credentials = credentials.concat(teamAppCredentials);
|
||||
}
|
||||
} else {
|
||||
if (slug !== "stripe") {
|
||||
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
||||
const { credentials: allCredentials } =
|
||||
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
|
||||
user: {
|
||||
...user,
|
||||
credentials,
|
||||
},
|
||||
});
|
||||
credentials = allCredentials;
|
||||
}
|
||||
}
|
||||
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
||||
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
|
||||
|
||||
const enabledApps = await getEnabledAppsFromCredentials(
|
||||
credentials as unknown as CredentialDataWithTeamName[],
|
||||
{
|
||||
user: {
|
||||
...user,
|
||||
credentials,
|
||||
},
|
||||
where: { slug },
|
||||
}
|
||||
);
|
||||
credentials = allCredentials;
|
||||
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
|
||||
where: { slug },
|
||||
});
|
||||
const apps = await Promise.all(
|
||||
enabledApps
|
||||
.filter(({ ...app }) => app.slug === slug)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { PlatformPlanType, orderedPlans } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
@@ -61,7 +61,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
!hasMinimumPlan({
|
||||
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
|
||||
minimumPlan: minimumPlan,
|
||||
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||
plans: orderedPlans,
|
||||
})
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
@@ -79,7 +79,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
type HasMinimumPlanProp = {
|
||||
currentPlan: PlatformPlanType;
|
||||
minimumPlan: PlatformPlanType;
|
||||
plans: PlatformPlanType[];
|
||||
plans: readonly PlatformPlanType[];
|
||||
};
|
||||
|
||||
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
||||
@@ -87,7 +87,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
||||
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
|
||||
|
||||
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||
throw new Error(
|
||||
throw new ForbiddenException(
|
||||
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ export class BillingController {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
|
||||
@@ -216,6 +216,30 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentPastDue(event: Stripe.Event) {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
const customerId = this.getCustomerIdFromInvoice(invoice);
|
||||
|
||||
if (subscriptionId && customerId) {
|
||||
const existingUserSubscription = await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (existingUserSubscription.status === "past_due") {
|
||||
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
|
||||
}
|
||||
|
||||
if (existingUserSubscription.status === "active") {
|
||||
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!subscriptionId || !customerId) {
|
||||
this.logger.log(`SubscriptionId: ${subscriptionId} or customerId: ${customerId} missing`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeCheckoutEvents(event: Stripe.Event) {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
|
||||
@@ -7,4 +7,13 @@ export enum PlatformPlan {
|
||||
PER_ACTIVE_USER = "PER_ACTIVE_USER",
|
||||
}
|
||||
|
||||
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
|
||||
export const orderedPlans = [
|
||||
"FREE",
|
||||
"STARTER",
|
||||
"ESSENTIALS",
|
||||
"SCALE",
|
||||
"PER_ACTIVE_USER",
|
||||
"ENTERPRISE",
|
||||
] as const;
|
||||
|
||||
export type PlatformPlanType = (typeof orderedPlans)[number];
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("OAuth client WebhooksController (e2e)", () => {
|
||||
let webhook: OAuthClientWebhookOutputResponseDto["data"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withNextAuth(
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
|
||||
import { IsOAuthClientWebhookGuard } from "@/modules/webhooks/guards/is-oauth-client-webhook-guard";
|
||||
@@ -29,7 +29,7 @@ import { OAuthClientGuard } from "../../guards/oauth-client-guard";
|
||||
path: "/v2/oauth-clients/:clientId/webhooks",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
|
||||
@DocsTags("Platform / Webhooks")
|
||||
@ApiHeader({
|
||||
name: X_CAL_SECRET_KEY,
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsDelegationCredentialRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async findById(delegationCredentialId: string) {
|
||||
return this.dbRead.prisma.delegationCredential.findUnique({ where: { id: delegationCredentialId } });
|
||||
@@ -22,7 +23,7 @@ export class OrganizationsDelegationCredentialRepository {
|
||||
delegationCredentialId: string,
|
||||
data: Prisma.DelegationCredentialUncheckedUpdateInput
|
||||
) {
|
||||
return this.dbRead.prisma.delegationCredential.update({
|
||||
return this.dbWrite.prisma.delegationCredential.update({
|
||||
where: { id: delegationCredentialId },
|
||||
data,
|
||||
include: { workspacePlatform: true },
|
||||
|
||||
+11
-5
@@ -47,7 +47,7 @@ export class OrganizationsDelegationCredentialService {
|
||||
delegatedServiceAccountUser: User,
|
||||
body: UpdateDelegationCredentialInput
|
||||
) {
|
||||
const delegationCredential =
|
||||
let delegationCredential =
|
||||
await this.organizationsDelegationCredentialRepository.findByIdWithWorkspacePlatform(
|
||||
delegationCredentialId
|
||||
);
|
||||
@@ -56,6 +56,14 @@ export class OrganizationsDelegationCredentialService {
|
||||
throw new NotFoundException(`DelegationCredential with id ${delegationCredentialId} not found`);
|
||||
}
|
||||
|
||||
if (body.serviceAccountKey !== undefined) {
|
||||
const updatedDelegationCredential = await this.updateDelegationCredentialServiceAccountKey(
|
||||
delegationCredential.id,
|
||||
body.serviceAccountKey
|
||||
);
|
||||
delegationCredential = updatedDelegationCredential ?? delegationCredential;
|
||||
}
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
await this.updateDelegationCredentialEnabled(
|
||||
orgId,
|
||||
@@ -64,16 +72,13 @@ export class OrganizationsDelegationCredentialService {
|
||||
body.enabled
|
||||
);
|
||||
}
|
||||
if (body.serviceAccountKey !== undefined) {
|
||||
await this.updateDelegationCredentialServiceAccountKey(delegationCredentialId, body.serviceAccountKey);
|
||||
}
|
||||
|
||||
// once delegation credentials are enabled, slowly set all the destination calendars of delegated users
|
||||
if (body.enabled === true && delegationCredential.enabled === false) {
|
||||
await this.ensureDefaultCalendars(orgId, delegationCredential.domain);
|
||||
}
|
||||
|
||||
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential?.enabled };
|
||||
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential.enabled };
|
||||
}
|
||||
|
||||
async ensureDefaultCalendars(orgId: number, domain: string) {
|
||||
@@ -132,6 +137,7 @@ export class OrganizationsDelegationCredentialService {
|
||||
delegationCredentialId,
|
||||
{
|
||||
serviceAccountKey: encryptedServiceAccountKey,
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
return delegationCredential;
|
||||
|
||||
+17
@@ -89,11 +89,13 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
teammate1 = await userRepositoryFixture.create({
|
||||
email: teammate1Email,
|
||||
username: teammate1Email,
|
||||
name: "alice",
|
||||
});
|
||||
|
||||
teammate2 = await userRepositoryFixture.create({
|
||||
email: teammate2Email,
|
||||
username: teammate2Email,
|
||||
name: "bob",
|
||||
});
|
||||
|
||||
falseTestUser = await userRepositoryFixture.create({
|
||||
@@ -432,6 +434,21 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.hosts).toEqual([
|
||||
{
|
||||
userId: teammate1.id,
|
||||
name: teammate1.name,
|
||||
username: teammate1.username,
|
||||
avatarUrl: teammate1.avatarUrl,
|
||||
},
|
||||
{
|
||||
userId: teammate2.id,
|
||||
name: teammate2.name,
|
||||
username: teammate2.username,
|
||||
avatarUrl: teammate2.avatarUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!responseTeamEvent) {
|
||||
throw new Error("Team event not found");
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
const hosts =
|
||||
databaseEventType.schedulingType === "MANAGED"
|
||||
? await this.getManagedEventTypeHosts(databaseEventType.id)
|
||||
: await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType);
|
||||
: await this.getNonManagedEventTypeHosts(databaseEventType.hosts, databaseEventType.schedulingType);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
@@ -138,13 +138,18 @@ export class OutputOrganizationsEventTypesService {
|
||||
for (const child of children) {
|
||||
if (child.userId) {
|
||||
const user = await this.usersRepository.findById(child.userId);
|
||||
transformedHosts.push({ userId: child.userId, name: user?.name || "" });
|
||||
transformedHosts.push({
|
||||
userId: child.userId,
|
||||
name: user?.name || "",
|
||||
username: user?.username || "",
|
||||
avatarUrl: user?.avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return transformedHosts;
|
||||
}
|
||||
|
||||
async transformHosts(
|
||||
async getNonManagedEventTypeHosts(
|
||||
databaseHosts: Host[],
|
||||
schedulingType: SchedulingType | null
|
||||
): Promise<TeamEventTypeResponseHost[]> {
|
||||
@@ -160,6 +165,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
transformedHosts.push({
|
||||
userId: databaseHost.userId,
|
||||
name: databaseUser?.name || "",
|
||||
username: databaseUser?.username || "",
|
||||
mandatory: !!databaseHost.isFixed,
|
||||
priority: getPriorityLabel(databaseHost.priority || 2),
|
||||
avatarUrl: databaseUser?.avatarUrl,
|
||||
@@ -168,6 +174,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
transformedHosts.push({
|
||||
userId: databaseHost.userId,
|
||||
name: databaseUser?.name || "",
|
||||
username: databaseUser?.username || "",
|
||||
avatarUrl: databaseUser?.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
+22
-6
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -34,11 +35,26 @@ export class ManagedOrganizationsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getByManagerOrganizationId(managerOrganizationId: number) {
|
||||
return this.dbRead.prisma.managedOrganization.findMany({
|
||||
where: {
|
||||
managerOrganizationId,
|
||||
},
|
||||
});
|
||||
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
|
||||
const { skip, take } = pagination;
|
||||
|
||||
const where: Prisma.ManagedOrganizationWhereInput = {
|
||||
managerOrganizationId,
|
||||
};
|
||||
|
||||
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
|
||||
this.dbRead.prisma.managedOrganization.count({ where }),
|
||||
this.dbRead.prisma.managedOrganization.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { managedOrganizationId: "asc" },
|
||||
include: { managedOrganization: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const items = linkRows.map((l) => l.managedOrganization);
|
||||
|
||||
return { totalItems, items };
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -7,6 +7,7 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
|
||||
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
|
||||
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
|
||||
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
|
||||
import { GetManagedOrganizationsOutput } from "@/modules/organizations/organizations/outputs/get-managed-organizations.output";
|
||||
import {
|
||||
ManagedOrganizationWithApiKeyOutput,
|
||||
ManagedOrganizationOutput,
|
||||
@@ -304,7 +305,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput[]> = response.body;
|
||||
const responseBody: GetManagedOrganizationsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseManagedOrgs = responseBody.data;
|
||||
expect(responseManagedOrgs?.length).toEqual(1);
|
||||
@@ -312,6 +313,14 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
expect(responseManagedOrg?.id).toBeDefined();
|
||||
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
|
||||
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
|
||||
|
||||
expect(responseBody.pagination).toBeDefined();
|
||||
expect(responseBody.pagination.totalItems).toEqual(1);
|
||||
expect(responseBody.pagination.remainingItems).toEqual(0);
|
||||
expect(responseBody.pagination.returnedItems).toEqual(1);
|
||||
expect(responseBody.pagination.itemsPerPage).toEqual(250);
|
||||
expect(responseBody.pagination.currentPage).toEqual(1);
|
||||
expect(responseBody.pagination.totalPages).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+7
-4
@@ -28,10 +28,12 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
const SCALE = "SCALE";
|
||||
|
||||
@@ -99,14 +101,15 @@ export class OrganizationsOrganizationsController {
|
||||
"Requires the user to have at least the 'ORG_ADMIN' role within the organization. Additionally, for platform, the plan must be 'SCALE' or higher to access this endpoint.",
|
||||
})
|
||||
async getOrganizations(
|
||||
@Param("orgId", ParseIntPipe) managerOrganizationId: number
|
||||
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
|
||||
@Query() queryPagination: SkipTakePagination
|
||||
): Promise<GetManagedOrganizationsOutput> {
|
||||
const organizations = await this.managedOrganizationsService.getManagedOrganizations(
|
||||
managerOrganizationId
|
||||
);
|
||||
const { organizations, pagination: responsePagination } =
|
||||
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: organizations,
|
||||
pagination: responsePagination,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -4,6 +4,7 @@ import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { PaginationMetaDto } from "@calcom/platform-types";
|
||||
|
||||
export class GetManagedOrganizationsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@@ -17,4 +18,9 @@ export class GetManagedOrganizationsOutput {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ManagedOrganizationOutput)
|
||||
data!: ManagedOrganizationOutput[];
|
||||
|
||||
@ApiProperty({ type: () => PaginationMetaDto })
|
||||
@Type(() => PaginationMetaDto)
|
||||
@ValidateNested()
|
||||
pagination!: PaginationMetaDto;
|
||||
}
|
||||
|
||||
+17
-11
@@ -1,3 +1,4 @@
|
||||
import { getPagination } from "@/lib/pagination/pagination";
|
||||
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||
@@ -11,6 +12,7 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsService {
|
||||
@@ -99,18 +101,22 @@ export class ManagedOrganizationsService {
|
||||
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
|
||||
}
|
||||
|
||||
async getManagedOrganizations(managerOrganizationId: number) {
|
||||
const managedOrganizations = await this.managedOrganizationsRepository.getByManagerOrganizationId(
|
||||
managerOrganizationId
|
||||
);
|
||||
const managedOrganizationsIds = managedOrganizations.map(
|
||||
(managedOrganization) => managedOrganization.managedOrganizationId
|
||||
);
|
||||
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
|
||||
const { items: managedOrganizations, totalItems } =
|
||||
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
|
||||
managerOrganizationId,
|
||||
pagination
|
||||
);
|
||||
|
||||
const organizations = await this.organizationsRepository.findByIds(managedOrganizationsIds);
|
||||
return organizations.map((organization) =>
|
||||
this.managedOrganizationsOutputService.getOutputManagedOrganization(organization)
|
||||
);
|
||||
return {
|
||||
organizations: managedOrganizations.map((managedOrganization) =>
|
||||
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
|
||||
),
|
||||
pagination: getPagination({
|
||||
...pagination,
|
||||
totalCount: totalItems,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async updateManagedOrganization(managedOrganizationId: number, body: UpdateOrganizationInput) {
|
||||
|
||||
+423
@@ -1501,6 +1501,429 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking status", () => {
|
||||
const startTime = "2050-09-12T11:00:00.000Z";
|
||||
const testBookingUid = `booking-uid-${eventTypeId}-booking-status-test`;
|
||||
|
||||
describe("cant reserve", () => {
|
||||
it("should not be able to reserve a slot if booking is accepted during that time", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
status: "ACCEPTED",
|
||||
uid: testBookingUid,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-12T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: startTime,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(
|
||||
`Can't reserve a slot if the event is already booked.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should not be able to reserve a slot if booking is pending during that time", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
status: "PENDING",
|
||||
uid: testBookingUid,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-12T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: startTime,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(
|
||||
`Can't reserve a slot if the event is already booked.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should not be able to reserve a slot if booking is awaiting host during that time", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
status: "AWAITING_HOST",
|
||||
uid: testBookingUid,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-12T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: startTime,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(
|
||||
`Can't reserve a slot if the event is already booked.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("can reserve", () => {
|
||||
it("should be able to reserve a slot if booking is cancelled during that time", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
uid: testBookingUid,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-12T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: startTime,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
|
||||
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
|
||||
expect(responseReservedSlot.reservationUid).toBeDefined();
|
||||
expect(responseReservedSlot.eventTypeId).toEqual(eventTypeId);
|
||||
expect(responseReservedSlot.slotStart).toEqual(startTime);
|
||||
expect(responseReservedSlot.slotDuration).toEqual(eventTypeLength);
|
||||
expect(responseReservedSlot.slotEnd).toEqual(
|
||||
DateTime.fromISO(startTime, { zone: "UTC" }).plus({ minutes: eventTypeLength }).toISO()
|
||||
);
|
||||
expect(responseReservedSlot.reservationDuration).toEqual(5);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(responseReservedSlot.reservationUid);
|
||||
});
|
||||
|
||||
it("should be able to reserve a slot if booking is rejected during that time", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create({
|
||||
status: "REJECTED",
|
||||
uid: testBookingUid,
|
||||
title: "booking title",
|
||||
startTime,
|
||||
endTime: "2050-09-12T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({
|
||||
eventTypeId,
|
||||
slotStart: startTime,
|
||||
})
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
|
||||
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
|
||||
expect(responseReservedSlot.reservationUid).toBeDefined();
|
||||
expect(responseReservedSlot.eventTypeId).toEqual(eventTypeId);
|
||||
expect(responseReservedSlot.slotStart).toEqual(startTime);
|
||||
expect(responseReservedSlot.slotDuration).toEqual(eventTypeLength);
|
||||
expect(responseReservedSlot.slotEnd).toEqual(
|
||||
DateTime.fromISO(startTime, { zone: "UTC" }).plus({ minutes: eventTypeLength }).toISO()
|
||||
);
|
||||
expect(responseReservedSlot.reservationDuration).toEqual(5);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking overlap", () => {
|
||||
const T0901 = DateTime.fromISO("2055-01-01T10:00:00.000Z").minus({ minutes: 59 }).toISO();
|
||||
const T0930 = "2055-01-01T09:30:00.000Z";
|
||||
const T1000 = "2055-01-01T10:00:00.000Z";
|
||||
const T1015 = "2055-01-01T10:15:00.000Z";
|
||||
const T1029 = DateTime.fromISO("2055-01-01T10:30:00.000Z").minus({ minutes: 1 }).toISO();
|
||||
const T1030 = "2055-01-01T10:30:00.000Z";
|
||||
const T1045 = "2055-01-01T10:45:00.000Z";
|
||||
const T1100 = "2055-01-01T11:00:00.000Z";
|
||||
const T1130 = "2055-01-01T11:30:00.000Z";
|
||||
const T1200 = "2055-01-01T12:00:00.000Z";
|
||||
|
||||
const getBookingPayload = (startTime: string, endTime: string) => ({
|
||||
status: "ACCEPTED" as const,
|
||||
uid: `test-booking-overlap-${randomString()}`,
|
||||
title: "Overlap Test Booking",
|
||||
startTime,
|
||||
endTime,
|
||||
eventType: { connect: { id: eventTypeId } },
|
||||
metadata: {},
|
||||
responses: { name: "Tester", email: `tester-overlap-${randomString()}@example.com`, guests: [] },
|
||||
user: { connect: { id: user.id } },
|
||||
});
|
||||
|
||||
const expectedErrorMessage = "Can't reserve a slot if the event is already booked.";
|
||||
describe("slot overlaps booking", () => {
|
||||
it("should fail if slot reservation (10:00-11:00) starts during an existing booking (09:30-10:30)", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1030));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1000 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (10:00-11:00) ends during an existing booking (10:30-11:30)", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1030, T1130));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1000 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (10:15-11:15) is entirely contained within an existing booking (10:00-11:30)", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1130));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1015 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (10:00-11:00) entirely contains an existing booking (10:15-10:45)", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1015, T1045));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1000 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (10:00-11:00) starts at the same time as an existing booking (10:00-11:00)", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1000 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (10:00-11:00) ends at the same time as an existing booking (09:30-11:00)", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1100));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1000 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (10:29-11:29) starts just before existing booking (09:30-10:30) ends", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1030));
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: T1029 })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
it("should fail if slot reservation (09:01-10:01) ends just after existing booking (10:00-11:00) starts", async () => {
|
||||
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
|
||||
const slotStartForEarlyOverlap = T0901;
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: slotStartForEarlyOverlap })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(422);
|
||||
|
||||
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slot does not overlap booking", () => {
|
||||
it("should succeed if slot reservation (09:00-10:00) is immediately before an existing booking (10:00-11:00)", async () => {
|
||||
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
|
||||
const slotStartForNoOverlap = "2055-01-01T09:00:00.000Z";
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
expect(reserveResponse.body.data.reservationUid).toBeDefined();
|
||||
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
|
||||
await bookingsRepositoryFixture.deleteById(existingBooking.id);
|
||||
});
|
||||
|
||||
it("should succeed if slot reservation (08:00-09:00) is before an existing booking (10:00-11:00)", async () => {
|
||||
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
|
||||
const slotStartForNoOverlap = "2055-01-01T08:00:00.000Z";
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
expect(reserveResponse.body.data.reservationUid).toBeDefined();
|
||||
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
|
||||
await bookingsRepositoryFixture.deleteById(existingBooking.id);
|
||||
});
|
||||
|
||||
it("should succeed if slot reservation (11:00-12:00) is immediately after an existing booking (10:00-11:00)", async () => {
|
||||
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
|
||||
const slotStartForNoOverlap = T1100;
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
expect(reserveResponse.body.data.reservationUid).toBeDefined();
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
|
||||
await bookingsRepositoryFixture.deleteById(existingBooking.id);
|
||||
});
|
||||
|
||||
it("should succeed if slot reservation (12:00-13:00) is after an existing booking (10:00-11:00)", async () => {
|
||||
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
|
||||
const slotStartForNoOverlap = T1200;
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/slots/reservations`)
|
||||
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(201);
|
||||
|
||||
expect(reserveResponse.body.data.reservationUid).toBeDefined();
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
|
||||
await bookingsRepositoryFixture.deleteById(existingBooking.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(oooTestUserEmail);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { z } from "zod";
|
||||
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { Booking, EventType } from "@calcom/prisma/client";
|
||||
|
||||
const eventTypeMetadataSchema = z
|
||||
.object({
|
||||
@@ -109,9 +109,10 @@ export class SlotsService_2024_09_04 {
|
||||
throw new BadRequestException("Invalid end date");
|
||||
}
|
||||
|
||||
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
|
||||
const booking = await this.slotsRepository.findActiveOverlappingBooking(
|
||||
input.eventTypeId,
|
||||
startDate.toJSDate()
|
||||
startDate.toJSDate(),
|
||||
endDate.toJSDate()
|
||||
);
|
||||
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
@@ -258,9 +259,10 @@ export class SlotsService_2024_09_04 {
|
||||
throw new BadRequestException("Invalid end date");
|
||||
}
|
||||
|
||||
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
|
||||
const booking = await this.slotsRepository.findActiveOverlappingBooking(
|
||||
input.eventTypeId,
|
||||
startDate.toJSDate()
|
||||
startDate.toJSDate(),
|
||||
endDate.toJSDate()
|
||||
);
|
||||
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
|
||||
@@ -12,10 +12,23 @@ export class SlotsRepository_2024_09_04 {
|
||||
return this.dbRead.prisma.selectedSlots.findFirst({ where: { uid } });
|
||||
}
|
||||
|
||||
async getBookingWithAttendeesByEventTypeIdAndStart(eventTypeId: number, startTime: Date) {
|
||||
async findActiveOverlappingBooking(eventTypeId: number, slotStartTime: Date, slotEndTime: Date) {
|
||||
return this.dbRead.prisma.booking.findFirst({
|
||||
where: { eventTypeId, startTime },
|
||||
select: { attendees: true },
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime: {
|
||||
// note(Lauris): booking starts before the potential slot reservation ends.
|
||||
lt: slotEndTime,
|
||||
},
|
||||
endTime: {
|
||||
// note(Lauris): booking ends after the potential slot reservation begins.
|
||||
gt: slotStartTime,
|
||||
},
|
||||
status: {
|
||||
in: ["ACCEPTED", "PENDING", "AWAITING_HOST"],
|
||||
},
|
||||
},
|
||||
select: { attendees: true, status: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -85,11 +85,13 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
teamMember1 = await userRepositoryFixture.create({
|
||||
email: teammate1Email,
|
||||
username: teammate1Email,
|
||||
name: "alice",
|
||||
});
|
||||
|
||||
teamMember2 = await userRepositoryFixture.create({
|
||||
email: teammate2Email,
|
||||
username: teammate2Email,
|
||||
name: "bob",
|
||||
});
|
||||
|
||||
falseTestUser = await userRepositoryFixture.create({
|
||||
@@ -403,6 +405,21 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.hosts).toEqual([
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
name: teamMember1.name,
|
||||
username: teamMember1.username,
|
||||
avatarUrl: teamMember1.avatarUrl,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
name: teamMember2.name,
|
||||
username: teamMember2.username,
|
||||
avatarUrl: teamMember2.avatarUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!responseTeamEvent) {
|
||||
throw new Error("Team event not found");
|
||||
}
|
||||
|
||||
@@ -446,9 +446,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -456,9 +459,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -654,9 +659,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -664,9 +672,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -1526,6 +1536,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by the booking Uid.",
|
||||
"example": "2NtaeaVcKfpmSZ4CthFdfk",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeIds",
|
||||
"required": false,
|
||||
@@ -1928,9 +1948,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -1938,9 +1961,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -2629,9 +2654,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -2639,9 +2667,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -2707,9 +2737,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -2717,9 +2750,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -2851,9 +2886,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -2861,9 +2899,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -4185,9 +4225,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -4195,9 +4238,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -4271,9 +4316,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -4281,9 +4329,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -5442,6 +5492,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by the booking Uid.",
|
||||
"example": "2NtaeaVcKfpmSZ4CthFdfk",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeIds",
|
||||
"required": false,
|
||||
@@ -5675,9 +5735,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -5685,9 +5748,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -5951,9 +6016,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -5961,9 +6029,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -6388,9 +6458,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -6398,9 +6471,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -6819,6 +6894,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by the booking Uid.",
|
||||
"example": "2NtaeaVcKfpmSZ4CthFdfk",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeIds",
|
||||
"required": false,
|
||||
@@ -7251,7 +7336,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_reassignBooking",
|
||||
"summary": "Reassign a booking to auto-selected host",
|
||||
"description": "The provided authorization header refers to the owner of the booking.",
|
||||
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -7302,7 +7387,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
|
||||
"summary": "Reassign a booking to a specific host",
|
||||
"description": "The provided authorization header refers to the owner of the booking.",
|
||||
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8741,9 +8826,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -8751,9 +8839,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -9012,6 +9102,31 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -9670,9 +9785,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -9680,9 +9798,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -9730,9 +9850,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -9740,9 +9863,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -11573,9 +11698,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -11583,9 +11711,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -11962,9 +12092,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -11972,9 +12105,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12022,9 +12157,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12032,9 +12170,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12342,9 +12482,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12352,9 +12495,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12394,9 +12539,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12404,9 +12552,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12576,9 +12726,12 @@
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"description": "Maximum number of items to return",
|
||||
"example": 25,
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 250,
|
||||
"default": 250,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -12586,9 +12739,11 @@
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"description": "Number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -15029,6 +15184,7 @@
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"example": "123 Example St, City, Country"
|
||||
},
|
||||
"public": {
|
||||
@@ -18904,6 +19060,10 @@
|
||||
"type": "string",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"example": "john-doe"
|
||||
},
|
||||
"avatarUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
@@ -18912,7 +19072,8 @@
|
||||
},
|
||||
"required": [
|
||||
"userId",
|
||||
"name"
|
||||
"name",
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"EventTypeTeam": {
|
||||
@@ -21967,6 +22128,66 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"PaginationMetaDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItems": {
|
||||
"type": "number",
|
||||
"description": "The total number of items available across all pages, matching the query criteria.",
|
||||
"example": 123,
|
||||
"minimum": 0
|
||||
},
|
||||
"remainingItems": {
|
||||
"type": "number",
|
||||
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
|
||||
"example": 103,
|
||||
"minimum": 0
|
||||
},
|
||||
"returnedItems": {
|
||||
"type": "number",
|
||||
"description": "The number of items returned in the current page.",
|
||||
"example": 10
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"type": "number",
|
||||
"description": "The maximum number of items requested per page.",
|
||||
"example": 10,
|
||||
"minimum": 1
|
||||
},
|
||||
"currentPage": {
|
||||
"type": "number",
|
||||
"description": "The current page number being returned.",
|
||||
"example": 2,
|
||||
"minimum": 1
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"description": "The total number of pages available.",
|
||||
"example": 13,
|
||||
"minimum": 0
|
||||
},
|
||||
"hasNextPage": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if there is a subsequent page available after the current one.",
|
||||
"example": true
|
||||
},
|
||||
"hasPreviousPage": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if there is a preceding page available before the current one.",
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalItems",
|
||||
"remainingItems",
|
||||
"returnedItems",
|
||||
"itemsPerPage",
|
||||
"currentPage",
|
||||
"totalPages",
|
||||
"hasNextPage",
|
||||
"hasPreviousPage"
|
||||
]
|
||||
},
|
||||
"GetManagedOrganizationsOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -21983,11 +22204,15 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ManagedOrganizationOutput"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/components/schemas/PaginationMetaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
"data",
|
||||
"pagination"
|
||||
]
|
||||
},
|
||||
"UpdateOrganizationInput": {
|
||||
@@ -24174,60 +24399,6 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"PaginationMetaDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItems": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "The total number of items available across all pages, matching the query criteria.",
|
||||
"example": 123
|
||||
},
|
||||
"remainingItems": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
|
||||
"example": 103
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"description": "The maximum number of items requested per page.",
|
||||
"example": 10
|
||||
},
|
||||
"currentPage": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"description": "The current page number being returned.",
|
||||
"example": 2
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "The total number of pages available.",
|
||||
"example": 13
|
||||
},
|
||||
"hasNextPage": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if there is a subsequent page available after the current one.",
|
||||
"example": true
|
||||
},
|
||||
"hasPreviousPage": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if there is a preceding page available before the current one.",
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalItems",
|
||||
"remainingItems",
|
||||
"itemsPerPage",
|
||||
"currentPage",
|
||||
"totalPages",
|
||||
"hasNextPage",
|
||||
"hasPreviousPage"
|
||||
]
|
||||
},
|
||||
"GetBookingsOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
@@ -21,16 +23,53 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getCachedMe = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
|
||||
return await meCaller.get();
|
||||
},
|
||||
["viewer.me.get"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const getCachedAvailabilities = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const availabilityCaller = await createRouterCaller(
|
||||
availabilityRouter,
|
||||
await getTRPCContext(headers, cookies)
|
||||
);
|
||||
return await availabilityCaller.list();
|
||||
},
|
||||
["viewer.availability.list"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const t = await getTranslate();
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
|
||||
const [meCaller, availabilityCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(availabilityRouter),
|
||||
const [me, cachedAvailabilities] = await Promise.all([
|
||||
getCachedMe(_headers, _cookies),
|
||||
getCachedAvailabilities(_headers, _cookies),
|
||||
]);
|
||||
|
||||
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
|
||||
// Transform the data to ensure startTime, endTime, and date are Date objects
|
||||
// This is because the data is cached and as a result the data is converted to a string
|
||||
const availabilities = {
|
||||
...cachedAvailabilities,
|
||||
schedules: cachedAvailabilities.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
availability: schedule.availability.map((avail) => ({
|
||||
...avail,
|
||||
startTime: new Date(avail.startTime),
|
||||
endTime: new Date(avail.endTime),
|
||||
date: avail.date ? new Date(avail.date) : null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
|
||||
const isOrgPrivate = organizationId
|
||||
? await OrganizationRepository.checkIfPrivate({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -23,9 +24,40 @@ export const generateMetadata = async () =>
|
||||
"/event-types"
|
||||
);
|
||||
|
||||
const getCachedMe = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
|
||||
return await meCaller.get();
|
||||
},
|
||||
["viewer.me.get"],
|
||||
{ revalidate: 3600 } // seconds
|
||||
);
|
||||
|
||||
const getCachedEventGroups = unstable_cache(
|
||||
async (
|
||||
headers: ReadonlyHeaders,
|
||||
cookies: ReadonlyRequestCookies,
|
||||
filters?: {
|
||||
teamIds?: number[] | undefined;
|
||||
userIds?: number[] | undefined;
|
||||
upIds?: string[] | undefined;
|
||||
}
|
||||
) => {
|
||||
const eventTypesCaller = await createRouterCaller(
|
||||
eventTypesRouter,
|
||||
await getTRPCContext(headers, cookies)
|
||||
);
|
||||
return await eventTypesCaller.getUserEventGroups({ filters });
|
||||
},
|
||||
["viewer.eventTypes.getUserEventGroups"],
|
||||
{ revalidate: 3600 } // seconds
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
const _searchParams = await searchParams;
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, _searchParams);
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
const context = buildLegacyCtx(_headers, _cookies, await params, _searchParams);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -34,15 +66,9 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
|
||||
const [meCaller, eventTypesCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(eventTypesRouter),
|
||||
]);
|
||||
|
||||
const [me, userEventGroupsData] = await Promise.all([
|
||||
meCaller.get(),
|
||||
eventTypesCaller.getUserEventGroups({ filters }),
|
||||
getCachedMe(_headers, _cookies),
|
||||
getCachedEventGroups(_headers, _cookies, filters),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -56,4 +82,3 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export const revalidate = 3600; // 1 hour in seconds
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import FormProvider from "./FormProvider";
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
return <FormProvider>{children}</FormProvider>;
|
||||
}
|
||||
@@ -5,29 +5,23 @@ import { cookies, headers } from "next/headers";
|
||||
|
||||
import { routingFormsComponents } from "@calcom/app-store/routing-forms/pages/app-routing.client-config";
|
||||
import type { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/routing-forms/[...pages]/getServerSideProps";
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import FormProvider from "./FormProvider";
|
||||
|
||||
const normalizePages = (pages: string[] | string | undefined) => {
|
||||
const normalizedPages = Array.isArray(pages) ? pages : pages?.split("/") ?? [];
|
||||
return {
|
||||
mainPage: normalizedPages[0] ?? "forms",
|
||||
subPages: normalizedPages.slice(1),
|
||||
};
|
||||
return normalizedPages[0];
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
|
||||
const { mainPage } = normalizePages((await params).pages);
|
||||
const mainPage = normalizePages((await params).pages);
|
||||
return await _generateMetadata(
|
||||
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/apps/routing-forms/${(await params).pages.join("/")}`
|
||||
`/routing/${(await params).pages.join("/")}`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,25 +32,11 @@ const getData = withAppDirSsr<GetServerSidePropsResult>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
const { mainPage } = normalizePages((await params).pages);
|
||||
const mainPage = normalizePages((await params).pages);
|
||||
|
||||
const Component = await routingFormsComponents[mainPage as keyof typeof routingFormsComponents]();
|
||||
const FinalComponent = () => (
|
||||
<FormProvider>
|
||||
<Component {...(props as any)} />
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
// TODO: we should really be using layout for this.
|
||||
if (mainPage === "forms") {
|
||||
return (
|
||||
<Shell withoutMain>
|
||||
<FinalComponent />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return <FinalComponent />;
|
||||
return <Component {...(props as any)} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import Forms from "@calcom/app-store/routing-forms/pages/forms/[...appPages]";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
|
||||
const pages = (await params).pages;
|
||||
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("routing_forms")} | Cal.com Forms`,
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/routing/forms/${pages?.length > 0 ? pages.join("/") : ""}`
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
return <Forms appUrl="/routing" />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import FormProvider from "../[...pages]/FormProvider";
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
return (
|
||||
<Shell withoutMain={true}>
|
||||
<FormProvider>{children}</FormProvider>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
@@ -30,6 +31,15 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getCachedEventType = unstable_cache(
|
||||
async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies));
|
||||
return await caller.get({ id: eventTypeId });
|
||||
},
|
||||
["viewer.eventTypes.get"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const ServerPage = async ({ params }: PageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -41,8 +51,10 @@ const ServerPage = async ({ params }: PageProps) => {
|
||||
throw new Error("Invalid Event Type id");
|
||||
}
|
||||
const eventTypeId = parsed.data.type;
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const data = await caller.get({ id: eventTypeId });
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
|
||||
const data = await getCachedEventType(eventTypeId, _headers, _cookies);
|
||||
if (!data?.eventType) {
|
||||
throw new Error("This event type does not exist");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("organization_members")}
|
||||
subtitle={t("organization_description")}
|
||||
headerClassName="hidden md:block"
|
||||
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import MembersPage from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("organization_members"),
|
||||
@@ -11,8 +9,4 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/organizations/${(await params).id}/members`
|
||||
);
|
||||
|
||||
const ServerPageWrapper = () => {
|
||||
return <MembersPage />;
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
export { default } from "../../members/page";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateAttributesList() {
|
||||
revalidateTag("viewer.attributes.list");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SettingsLayoutAppDirClient from "app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient";
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
|
||||
<SettingsHeader
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
|
||||
{children}
|
||||
</SettingsHeader>
|
||||
</SettingsLayoutAppDirClient>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import SettingsLayoutAppDir from "app/(use-page-wrapper)/settings/(settings-layout)/layout";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/members";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -14,18 +16,25 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/members"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const getCachedAttributes = unstable_cache(
|
||||
async (orgId: number) => {
|
||||
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const children = (
|
||||
<SettingsHeader
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
const Page = async () => {
|
||||
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
||||
const [org, teams, facetedTeamValues] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
]);
|
||||
const attributes = await getCachedAttributes(org.id);
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
);
|
||||
return await SettingsLayoutAppDir({ children, containerClassName: "lg:max-w-screen-2xl" });
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={
|
||||
<div className="flex">
|
||||
<h1>Member management</h1>
|
||||
<Button
|
||||
tooltip="Only teammates invited as admins can create OAuth clients while teammates invited as members have read only access"
|
||||
tooltipSide="right"
|
||||
className="mx-2 hover:bg-transparent"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="info"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
title={t("platform_members")}
|
||||
subtitle={t("platform_members_description")}
|
||||
withoutMain={false}
|
||||
isPlatformUser={true}
|
||||
actions={<div className={CTA_CONTAINER_CLASS_NAME} />}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -11,8 +13,11 @@ export const generateMetadata = async () =>
|
||||
"/settings/platform/members"
|
||||
);
|
||||
|
||||
const ServerPageWrapper = () => {
|
||||
return <PlatformMembersView />;
|
||||
const ServerPageWrapper = async () => {
|
||||
const [orgCaller] = await Promise.all([createRouterCaller(viewerOrganizationsRouter)]);
|
||||
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
||||
|
||||
return <PlatformMembersView org={org} teams={teams} />;
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { TRPCContext } from "@calcom/trpc/types/server/createContext";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { AnyRouter } from "@trpc/server";
|
||||
|
||||
const getTRPCContext = async () => {
|
||||
const legacyReq = buildLegacyRequest(await headers(), await cookies());
|
||||
export const getTRPCContext = async (_headers?: ReadonlyHeaders, _cookies?: ReadonlyRequestCookies) => {
|
||||
const legacyReq = buildLegacyRequest(_headers ?? (await headers()), _cookies ?? (await cookies()));
|
||||
return await createContext({ req: legacyReq, res: {} as any }, getServerSession);
|
||||
};
|
||||
|
||||
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter) {
|
||||
const trpcContext = await getTRPCContext();
|
||||
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter, context?: TRPCContext) {
|
||||
const trpcContext = context ? context : await getTRPCContext();
|
||||
const createCaller = createCallerFactory<TRouter>(router);
|
||||
return createCaller(trpcContext);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,6 @@ export type PageProps = {
|
||||
};
|
||||
|
||||
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
|
||||
|
||||
export { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
export { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { handleCreateCredentials } from "../route";
|
||||
|
||||
type OrgParams = { id: number };
|
||||
const createOrg = async ({ id }: OrgParams) =>
|
||||
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
|
||||
|
||||
type UserParams = { id: number; email: string };
|
||||
const createUser = async ({ id, email }: UserParams) => await prismock.user.create({ data: { id, email } });
|
||||
|
||||
type WorkspacePlatformParams = { id: number; name?: string; slug?: string; description?: string };
|
||||
const createWorkspacePlatform = async ({ id, name, slug, description }: WorkspacePlatformParams) =>
|
||||
await prismock.workspacePlatform.create({
|
||||
data: {
|
||||
id,
|
||||
name: name ?? "Google",
|
||||
slug: slug ?? "google",
|
||||
description: description ?? "Test platform",
|
||||
defaultServiceAccountKey: {},
|
||||
},
|
||||
});
|
||||
|
||||
type DelegationCredentialParams = {
|
||||
id: string;
|
||||
orgId: number;
|
||||
domain: string;
|
||||
workspacePlatformId: number;
|
||||
};
|
||||
|
||||
const createDelegationCredential = async ({
|
||||
id,
|
||||
orgId,
|
||||
domain,
|
||||
workspacePlatformId,
|
||||
}: DelegationCredentialParams) =>
|
||||
await prismock.delegationCredential.create({
|
||||
data: {
|
||||
id,
|
||||
enabled: true,
|
||||
domain,
|
||||
organizationId: orgId,
|
||||
workspacePlatformId,
|
||||
serviceAccountKey: {
|
||||
client_email: "svc@example.com",
|
||||
private_key: "pk",
|
||||
client_id: "cid",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CredentialParams = { id?: number; userId?: number; delegationCredentialId?: string; key?: object };
|
||||
const createCredential = async ({
|
||||
id = 1,
|
||||
userId = 1,
|
||||
delegationCredentialId = "delegation-credential-1",
|
||||
key = {},
|
||||
}: CredentialParams = {}) =>
|
||||
await prismock.credential.create({
|
||||
data: {
|
||||
id,
|
||||
type: "google_calendar",
|
||||
key,
|
||||
userId,
|
||||
appId: "google_calendar",
|
||||
delegationCredentialId,
|
||||
},
|
||||
});
|
||||
|
||||
type MembershipParams = { teamId: number; userId: number; role?: MembershipRole };
|
||||
const createMembership = async ({ teamId, userId, role = "MEMBER" }: MembershipParams) =>
|
||||
await prismock.membership.create({ data: { teamId, userId, accepted: true, role } });
|
||||
|
||||
// Helper to assert credentials in DB match expected (partial, order-insensitive)
|
||||
type PartialCredential = Partial<Awaited<ReturnType<typeof prismock.credential.create>>>;
|
||||
async function expectCredentials(expected: PartialCredential[]) {
|
||||
const actual = await prismock.credential.findMany({});
|
||||
expect(actual.length).toBe(expected.length);
|
||||
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
|
||||
}
|
||||
|
||||
describe("Delegation Credentials: Organization-wide Google Calendar Access", () => {
|
||||
beforeEach(() => {
|
||||
prismock.delegationCredential.deleteMany();
|
||||
prismock.credential.deleteMany();
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
prismock.membership.deleteMany();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are enabled", async () => {
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result).toEqual({
|
||||
message: "No enabled delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
});
|
||||
await expectCredentials([]);
|
||||
});
|
||||
|
||||
it("creates credentials for all eligible organization members when Delegation Credential is enabled", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
|
||||
const user1 = await createUser({ id: 1, email: "alice@example.com" });
|
||||
const user2 = await createUser({ id: 2, email: "bob@example.com" });
|
||||
await createMembership({ teamId: org.id, userId: user1.id });
|
||||
await createMembership({ teamId: org.id, userId: user2.id });
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: org.id,
|
||||
domain: "example.com",
|
||||
workspacePlatformId: workspacePlatform.id,
|
||||
});
|
||||
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(2);
|
||||
expect(result.failures).toBe(0);
|
||||
await expectCredentials([
|
||||
{ userId: user1.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||
{ userId: user2.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips credential creation for unsupported workspace platforms", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
// Create a non-google workspace platform
|
||||
const outlookPlatform = await createWorkspacePlatform({
|
||||
id: 2,
|
||||
name: "Outlook",
|
||||
slug: "outlook",
|
||||
description: "Test",
|
||||
});
|
||||
const user = await createUser({ id: 1, email: "user1@outlook.com" });
|
||||
await createMembership({ teamId: org.id, userId: user.id });
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-2",
|
||||
orgId: org.id,
|
||||
domain: "outlook.com",
|
||||
workspacePlatformId: outlookPlatform.id,
|
||||
});
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(0);
|
||||
await expectCredentials([]);
|
||||
});
|
||||
|
||||
it("does not create duplicate credentials for users who already have one", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
await createMembership({ teamId: org.id, userId: user.id });
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: org.id,
|
||||
domain: "example.com",
|
||||
workspacePlatformId: workspacePlatform.id,
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: "delegation-credential-1" });
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(0);
|
||||
await expectCredentials([
|
||||
{ userId: user.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("processes only a batch of members at a time if there are more than the batch size", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
const workspacePlatform = await createWorkspacePlatform({
|
||||
id: 1,
|
||||
name: "Google",
|
||||
slug: "google",
|
||||
description: "Test platform",
|
||||
});
|
||||
// Simulate more than 100 delegated members
|
||||
const users = [];
|
||||
for (let i = 1; i <= 120; i++) {
|
||||
const user = await createUser({ id: i, email: `user${i}@example.com` });
|
||||
users.push(user);
|
||||
await createMembership({ teamId: org.id, userId: user.id });
|
||||
}
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: org.id,
|
||||
domain: "example.com",
|
||||
workspacePlatformId: workspacePlatform.id,
|
||||
});
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(100); // Only batch size processed
|
||||
await expectCredentials(
|
||||
users.slice(0, 100).map((u) => ({
|
||||
userId: u.id,
|
||||
delegationCredentialId: "delegation-credential-1",
|
||||
type: "google_calendar",
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { DelegationCredentialRepository } from "@calcom/lib/server/repository/de
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["CreateCredentials"] });
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[credentials/cron]"] });
|
||||
const batchSizeToCreateCredentials = 100;
|
||||
const validateRequest = (req: NextRequest) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -24,9 +24,12 @@ const validateRequest = (req: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateCredentials() {
|
||||
export async function handleCreateCredentials() {
|
||||
const log = moduleLogger.getSubLogger({ prefix: ["CreateCredentials"] });
|
||||
const delegationCredentials = await DelegationCredentialRepository.findAllEnabledIncludeDelegatedMembers();
|
||||
|
||||
log.info(`Found ${delegationCredentials.length} enabled delegation credentials to create credentials for`);
|
||||
|
||||
if (!delegationCredentials.length) {
|
||||
return {
|
||||
message: "No enabled delegation credentials found",
|
||||
@@ -107,9 +110,12 @@ async function handleCreateCredentials() {
|
||||
}
|
||||
|
||||
async function handleDeleteCredentials() {
|
||||
const log = moduleLogger.getSubLogger({ prefix: ["DeleteCredentials"] });
|
||||
const delegationCredentials =
|
||||
await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess();
|
||||
|
||||
log.info(`Found ${delegationCredentials.length} delegation credentials to delete credentials for`);
|
||||
|
||||
if (!delegationCredentials.length) {
|
||||
return {
|
||||
message: "No disabled delegation credentials found",
|
||||
|
||||
@@ -95,7 +95,7 @@ async function postHandler(request: NextRequest) {
|
||||
const userIdsFromTeams = team.members.map((u) => u.userId);
|
||||
|
||||
// Booking Events
|
||||
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
|
||||
const whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
@@ -122,7 +122,7 @@ async function postHandler(request: NextRequest) {
|
||||
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
|
||||
|
||||
// Most Booked Event Type
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
const bookingWhere: Prisma.BookingTimeStatusDenormalizedWhereInput = {
|
||||
createdAt: {
|
||||
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
|
||||
lte: dayjs(new Date()).endOf("day").toDate(),
|
||||
@@ -141,7 +141,7 @@ async function postHandler(request: NextRequest) {
|
||||
],
|
||||
};
|
||||
|
||||
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
|
||||
const bookingsFromSelected = await prisma.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["eventTypeId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -234,7 +234,7 @@ async function postHandler(request: NextRequest) {
|
||||
});
|
||||
|
||||
// Most booked members
|
||||
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await prisma.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
import "@calcom/lib/server/__mocks__/serviceAccountKey";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
|
||||
|
||||
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
||||
|
||||
// Mock GoogleCalendarService
|
||||
const fetchPrimaryCalendarMock = vi.fn();
|
||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
type OrgParams = { id?: number };
|
||||
const createOrg = async ({ id = 1 }: OrgParams = {}) =>
|
||||
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
|
||||
|
||||
type UserParams = { id?: number; email?: string };
|
||||
const createUser = async ({ id = 1, email = `user${id}@example.com` }: UserParams = {}) =>
|
||||
await prismock.user.create({ data: { id, email } });
|
||||
|
||||
type WorkspacePlatformParams = { id?: number };
|
||||
const createWorkspacePlatform = async ({ id = 1 }: WorkspacePlatformParams = {}) =>
|
||||
await prismock.workspacePlatform.create({
|
||||
data: {
|
||||
id,
|
||||
name: "Google",
|
||||
slug: "google",
|
||||
description: "Test platform",
|
||||
defaultServiceAccountKey: {},
|
||||
},
|
||||
});
|
||||
|
||||
type DelegationCredentialParams = { id: string; orgId: number; domain: string };
|
||||
const createDelegationCredential = async ({ id, orgId, domain }: DelegationCredentialParams) =>
|
||||
await prismock.delegationCredential.create({
|
||||
data: {
|
||||
id,
|
||||
enabled: true,
|
||||
domain: domain ?? "example.com",
|
||||
organizationId: orgId ?? 1,
|
||||
workspacePlatformId: 1,
|
||||
serviceAccountKey: {
|
||||
client_email: "svc@example.com",
|
||||
private_key: "pk",
|
||||
client_id: "cid",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CredentialParams = {
|
||||
id: number;
|
||||
userId: number;
|
||||
delegationCredentialId: string | null;
|
||||
key?: object;
|
||||
};
|
||||
const createCredential = async ({ id, userId, delegationCredentialId, key = {} }: CredentialParams) =>
|
||||
await prismock.credential.create({
|
||||
data: {
|
||||
id,
|
||||
type: "google_calendar",
|
||||
key,
|
||||
userId,
|
||||
appId: "google_calendar",
|
||||
delegationCredentialId,
|
||||
},
|
||||
});
|
||||
|
||||
type SelectedCalendarParams = {
|
||||
userId?: number;
|
||||
externalId?: string;
|
||||
error?: string | null;
|
||||
id?: string;
|
||||
delegationCredentialId?: string | null;
|
||||
credentialId?: number;
|
||||
};
|
||||
const createSelectedCalendar = async ({
|
||||
userId = 1,
|
||||
externalId = "user1@example.com",
|
||||
error = null,
|
||||
id = `${userId}-sc`,
|
||||
delegationCredentialId = null,
|
||||
credentialId = 1,
|
||||
}: SelectedCalendarParams = {}) =>
|
||||
await prismock.selectedCalendar.create({
|
||||
data: {
|
||||
id,
|
||||
integration: "google_calendar",
|
||||
externalId,
|
||||
userId,
|
||||
delegationCredentialId,
|
||||
credentialId,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to assert selected calendars in DB match expected (partial, order-insensitive)
|
||||
type PartialSelectedCalendar = Partial<Awaited<ReturnType<typeof prismock.selectedCalendar.create>>>;
|
||||
async function expectSelectedCalendars(expected: PartialSelectedCalendar[]) {
|
||||
const actual = await prismock.selectedCalendar.findMany({});
|
||||
expect(actual.length).toBe(expected.length);
|
||||
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
|
||||
}
|
||||
|
||||
describe("handleCreateSelectedCalendars integration", () => {
|
||||
beforeEach(() => {
|
||||
prismock.delegationCredential.deleteMany();
|
||||
prismock.credential.deleteMany();
|
||||
prismock.selectedCalendar.deleteMany();
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
fetchPrimaryCalendarMock.mockReset();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result).toEqual({
|
||||
message: "No delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("automatically creates a Selected Calendar for every eligible user when Delegation Credential is enabled and user matches domain", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
expect(result.failures).toBe(0);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("when the user's primary calendar ID does not match their email address", () => {
|
||||
it("creates a Selected Calendar with the primaryCalendarId as the externalId if user's email is not a plus based variant of primaryCalendarId", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
expect(result.failures).toBe(0);
|
||||
await expectSelectedCalendars([
|
||||
{
|
||||
userId: user.id,
|
||||
externalId: "notuser@example.com",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a Selected Calendar with the user's email as the externalId if user's email is a plus based variant of primaryCalendarId", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1+test@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
expect(result.failures).toBe(0);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: user.email, delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create duplicate Selected Calendars for users who already have a valid one", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
await createSelectedCalendar({
|
||||
userId: user.id,
|
||||
externalId: "user1@example.com",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
credentialId: 1,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces a Selected Calendar if the existing one has an error, ensuring users always have a working calendar connection", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
// Create a Delegation User Credential
|
||||
await createCredential({
|
||||
id: 2,
|
||||
userId: user.id,
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
// Create a Regular User Credential
|
||||
const regularCredential = await createCredential({
|
||||
id: 1,
|
||||
userId: user.id,
|
||||
delegationCredentialId: null,
|
||||
});
|
||||
// Create a SelectedCalendar attached to regular credential
|
||||
await createSelectedCalendar({
|
||||
userId: user.id,
|
||||
externalId: "user1@example.com",
|
||||
error: "some error" as unknown as null,
|
||||
credentialId: regularCredential.id,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
await expectSelectedCalendars([
|
||||
{
|
||||
userId: user.id,
|
||||
externalId: "user1@example.com",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("when GoogleCalendarService throws error", () => {
|
||||
it("creates SelectedCalendar with user.email when CalendarAppDelegationCredentialInvalidGrantError is thrown", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||
);
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not create SelectedCalendar when some other error than CalendarAppDelegationCredentialInvalidGrantError is thrown", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
await expectSelectedCalendars([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameEmail", () => {
|
||||
it("returns true if variant email is a plus based variant of main email", () => {
|
||||
expect(
|
||||
isSameEmail({
|
||||
mainEmail: "user1@example.com",
|
||||
variantEmail: "user1+test@example.com",
|
||||
emailProvider: "google",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if variant email is different from main email", () => {
|
||||
expect(
|
||||
isSameEmail({
|
||||
mainEmail: "user1@example.com",
|
||||
variantEmail: "user1@domain.com",
|
||||
emailProvider: "google",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true if variant email is same as main email but with different casing", () => {
|
||||
expect(
|
||||
isSameEmail({
|
||||
mainEmail: "User1@example.com",
|
||||
variantEmail: "user1+test@Example.com",
|
||||
emailProvider: "google",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
|
||||
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -19,7 +20,7 @@ import type { Ensure } from "@calcom/types/utils";
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const limitOnQueryingGoogleCalendar = 50;
|
||||
const log = logger.getSubLogger({ prefix: ["[api] selected-calendars/cron"] });
|
||||
const log = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[selected-calendars/cron]"] });
|
||||
const validateRequest = (req: NextRequest) => {
|
||||
const url = new URL(req.url);
|
||||
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
|
||||
@@ -28,13 +29,230 @@ const validateRequest = (req: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateSelectedCalendars() {
|
||||
export function isSameEmail({
|
||||
mainEmail,
|
||||
variantEmail,
|
||||
emailProvider,
|
||||
}: {
|
||||
mainEmail: string;
|
||||
variantEmail: string;
|
||||
emailProvider: "google";
|
||||
}) {
|
||||
if (emailProvider !== "google") {
|
||||
return mainEmail.toLowerCase() === variantEmail.toLowerCase();
|
||||
}
|
||||
const variantEmailParts = variantEmail.split("@");
|
||||
const variantEmailUserName = variantEmailParts[0];
|
||||
const variantEmailDomain = variantEmailParts[1];
|
||||
const variantEmailUserNameWithoutPlus = variantEmailUserName.split("+")[0];
|
||||
return mainEmail.toLowerCase() === `${variantEmailUserNameWithoutPlus}@${variantEmailDomain}`.toLowerCase();
|
||||
}
|
||||
|
||||
type DelegationUserCredential = Awaited<
|
||||
ReturnType<typeof CredentialRepository.findAllDelegationByTypeIncludeUserAndTake>
|
||||
>[number];
|
||||
|
||||
type DelegationUserCredentialWithEnsuredUser = Ensure<DelegationUserCredential, "user">;
|
||||
|
||||
async function getDelegationUserCredentialsToProcess(delegationUserCredentials: DelegationUserCredential[]) {
|
||||
const delegationUserCredentialsToProcess = await Promise.all(
|
||||
delegationUserCredentials
|
||||
.filter(
|
||||
(delegationUserCredential): delegationUserCredential is DelegationUserCredentialWithEnsuredUser =>
|
||||
delegationUserCredential.user !== null
|
||||
)
|
||||
.map(async (delegationUserCredential) => {
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
// Consider existing SelectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
|
||||
// It is also important because SelectedCalendar has composite unique constraint on userId, externalId and integration and thus we couldn't have the same values for different credentialId
|
||||
const selectedCalendar = await SelectedCalendarRepository.findFirst({
|
||||
where: {
|
||||
userId: delegationUserCredential.user.id,
|
||||
externalId: userEmail,
|
||||
integration: "google_calendar",
|
||||
},
|
||||
});
|
||||
|
||||
if (selectedCalendar) {
|
||||
if (selectedCalendar.delegationCredentialId === delegationUserCredential.delegationCredentialId) {
|
||||
// In case this SelectedCalendar has an error, we would let CalendarCache cron handle it
|
||||
log.info(
|
||||
`Found an existing SelectedCalendar for userId: ${delegationUserCredential.user.id} and delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!selectedCalendar.error) {
|
||||
log.info(`Found a reusable SelectedCalendar for userId: ${delegationUserCredential.user.id}`);
|
||||
return null;
|
||||
} else {
|
||||
log.error(
|
||||
`Found reusable Non-Delegation SelectedCalendar for ${delegationUserCredential.user.id} but it has an error: '${selectedCalendar.error}', so deleting it, new one will be created`
|
||||
);
|
||||
|
||||
// We found a SelectedCalendar attached to non-delegation credential, so we shouldn't reuse it as the non-delegation credential attached to it could be invalid
|
||||
// So, we delete it so that SelectedCalendarRepository allow creating a similar one with Delegation Credential
|
||||
// Also, we don't delete the possibly invalid credential due to false alarm and we would need that intact in case we disable Delegation Credential
|
||||
// Because credential isn't deleted, the corresponding CalendarCache entry would still exist and thus we will end up having two CalendarCache entries for same key and userId with different credentialId
|
||||
await SelectedCalendarRepository.deleteById({ id: selectedCalendar.id });
|
||||
return delegationUserCredential;
|
||||
}
|
||||
}
|
||||
return delegationUserCredential;
|
||||
})
|
||||
);
|
||||
|
||||
return delegationUserCredentialsToProcess.filter(
|
||||
(credential): credential is DelegationUserCredentialWithEnsuredUser => credential !== null
|
||||
);
|
||||
}
|
||||
|
||||
async function getCalendarService(delegationUserCredential: DelegationUserCredentialWithEnsuredUser) {
|
||||
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
});
|
||||
|
||||
if (!credentialForCalendarService) {
|
||||
log.error(
|
||||
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!credentialForCalendarService.delegatedTo ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
|
||||
) {
|
||||
log.error(
|
||||
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
|
||||
safeStringify({
|
||||
delegatedToSet: !!credentialForCalendarService.delegatedTo,
|
||||
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
|
||||
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const googleCalendarService = new GoogleCalendarService(
|
||||
credentialForCalendarService as CredentialForCalendarServiceWithEmail
|
||||
);
|
||||
|
||||
return googleCalendarService;
|
||||
}
|
||||
|
||||
async function fetchPrimaryCalendarId({
|
||||
googleCalendarService,
|
||||
delegationUserCredential,
|
||||
}: {
|
||||
googleCalendarService: GoogleCalendarService;
|
||||
delegationUserCredential: DelegationUserCredentialWithEnsuredUser;
|
||||
}) {
|
||||
let primaryCalendarId;
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
|
||||
try {
|
||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
primaryCalendarId = primaryCalendar?.id;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify(error)
|
||||
);
|
||||
|
||||
if (error instanceof CalendarAppDelegationCredentialInvalidGrantError) {
|
||||
// It is known to happen in case john+test@acme.com is the user email and the actual email is john@acme.com. Google rejects the authorization for john+test@acme.com
|
||||
log.info(
|
||||
`Error seems to be that the email isn't present in the third party workspace, further attempts would fail too, so we assume the user's email as the primary calendar id to skip this user in future cron runs`
|
||||
);
|
||||
// If we are unable to fetch the primary calendar, we assume the user's email as the primary calendar id
|
||||
// It ensures that this calendar is skipped in the future cron runs
|
||||
primaryCalendarId = userEmail;
|
||||
}
|
||||
}
|
||||
|
||||
return primaryCalendarId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SelectedCalendar record corresponding to Delegation User Credential - that would normally use primary calendar Id as the externalId but in some cases, it would use user's email as the externalId
|
||||
*/
|
||||
async function processDelegationUserCredential(
|
||||
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
|
||||
) {
|
||||
try {
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
const googleCalendarService = await getCalendarService(delegationUserCredential);
|
||||
if (!googleCalendarService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryCalendarId = await fetchPrimaryCalendarId({
|
||||
googleCalendarService,
|
||||
delegationUserCredential,
|
||||
});
|
||||
|
||||
if (!primaryCalendarId) {
|
||||
log.error(
|
||||
`External calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`
|
||||
);
|
||||
throw new Error("externalCalendarId could not be determined");
|
||||
}
|
||||
|
||||
let externalCalendarId = primaryCalendarId;
|
||||
|
||||
if (primaryCalendarId !== userEmail) {
|
||||
const areEmailsSame = isSameEmail({
|
||||
emailProvider: "google",
|
||||
mainEmail: primaryCalendarId,
|
||||
variantEmail: userEmail,
|
||||
});
|
||||
|
||||
if (areEmailsSame) {
|
||||
log.info(
|
||||
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}. Using userEmail as the externalCalendarId as it is a plus based variant of primaryCalendarId`
|
||||
);
|
||||
// We have found that for some plus based email addresses(like john+test@acme.com) fetching primary calendar is successful and the primaryCalendarId comes back as john@acme.com
|
||||
// Using userEmail as the externalCalendarId would ensure that the SelectedCalendar isn't attempted to be re-created and the SelectedCalendar record is still logically correct
|
||||
externalCalendarId = userEmail;
|
||||
} else {
|
||||
// Unknown reason for mismatch, so we log it.
|
||||
log.warn(
|
||||
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify({
|
||||
externalCalendarId,
|
||||
userEmail,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This creation call isn't expected to fail as we have already checked for the existence of the SelectedCalendar by userEmail
|
||||
await SelectedCalendarRepository.create({
|
||||
integration: "google_calendar",
|
||||
externalId: externalCalendarId,
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
credentialId: delegationUserCredential.id,
|
||||
});
|
||||
|
||||
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateSelectedCalendars() {
|
||||
// These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere
|
||||
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
|
||||
type: "google_calendar",
|
||||
take: limitOnQueryingGoogleCalendar,
|
||||
});
|
||||
|
||||
log.info(`Found ${allDelegationUserCredentials.length} delegation user credentials to process`);
|
||||
|
||||
if (!allDelegationUserCredentials.length) {
|
||||
const message = "No delegation credentials found";
|
||||
log.info(message);
|
||||
@@ -57,106 +275,16 @@ async function handleCreateSelectedCalendars() {
|
||||
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
|
||||
groupedDelegationUserCredentials
|
||||
)) {
|
||||
// First, create an array of promises that check if each credential should be processed
|
||||
const checkedDelegationUserCredentials = await Promise.all(
|
||||
delegationUserCredentials.map(async (delegationUserCredential) => {
|
||||
if (!delegationUserCredential.user) {
|
||||
log.error(`Credential ${delegationUserCredential.id} has no user`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all selectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
|
||||
const hasSelectedCalendarForUserEmail = !!(await SelectedCalendarRepository.findFirst({
|
||||
where: {
|
||||
userId: delegationUserCredential.user.id,
|
||||
externalId: delegationUserCredential.user.email,
|
||||
},
|
||||
}));
|
||||
|
||||
if (hasSelectedCalendarForUserEmail) {
|
||||
log.info(`Found a reusable SelectedCalendar for ${delegationUserCredential.user.id}`);
|
||||
return null;
|
||||
}
|
||||
return delegationUserCredential;
|
||||
})
|
||||
log.info(`Processing delegation user credentials for delegationCredentialId: ${delegationCredentialId}`);
|
||||
const delegationUserCredentialsToProcess = await getDelegationUserCredentialsToProcess(
|
||||
delegationUserCredentials
|
||||
);
|
||||
|
||||
const delegationUserCredentialsToProcess = checkedDelegationUserCredentials.filter(
|
||||
(credential): credential is Ensure<(typeof delegationUserCredentials)[number], "user"> =>
|
||||
credential !== null
|
||||
);
|
||||
|
||||
log.info(
|
||||
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
delegationUserCredentialsToProcess.map(async (delegationUserCredential) => {
|
||||
try {
|
||||
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
});
|
||||
|
||||
if (!credentialForCalendarService) {
|
||||
log.error(
|
||||
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!credentialForCalendarService.delegatedTo ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
|
||||
) {
|
||||
log.error(
|
||||
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
|
||||
safeStringify({
|
||||
delegatedToSet: !!credentialForCalendarService.delegatedTo,
|
||||
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
|
||||
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const googleCalendarService = new GoogleCalendarService(
|
||||
credentialForCalendarService as CredentialForCalendarServiceWithEmail
|
||||
);
|
||||
|
||||
let primaryCalendar;
|
||||
try {
|
||||
primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primaryCalendar || !primaryCalendar.id) {
|
||||
log.error(
|
||||
`Primary calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await SelectedCalendarRepository.create({
|
||||
integration: "google_calendar",
|
||||
externalId: primaryCalendar.id,
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
credentialId: delegationUserCredential.id,
|
||||
});
|
||||
|
||||
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
delegationUserCredentialsToProcess.map(processDelegationUserCredential)
|
||||
);
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failureCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
@@ -192,11 +192,12 @@ async function getHandler(request: NextRequest) {
|
||||
try {
|
||||
const response = await fetch(filteredLogo);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
let buffer = Buffer.from(arrayBuffer);
|
||||
let buffer: Buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// If we need to resize the team logos (via Next.js' built-in image processing)
|
||||
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
|
||||
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
|
||||
|
||||
buffer = await optimizeImage({
|
||||
buffer,
|
||||
contentType: detectContentType(buffer) ?? "image/jpeg",
|
||||
|
||||
@@ -9,15 +9,16 @@ import {
|
||||
sendDailyVideoRecordingEmails,
|
||||
sendDailyVideoTranscriptEmails,
|
||||
} from "@calcom/emails/daily-video-emails";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import {
|
||||
getAllTranscriptsAccessLinkFromMeetingId,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
submitBatchProcessorTranscriptionJob,
|
||||
} from "@calcom/lib/videoClient";
|
||||
import { generateVideoToken } from "@calcom/lib/videoTokens";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
|
||||
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
meetingEndedSchema,
|
||||
recordingReadySchema,
|
||||
batchProcessorJobFinishedSchema,
|
||||
downloadLinkSchema,
|
||||
testRequestSchema,
|
||||
} from "@calcom/web/lib/daily-webhook/schema";
|
||||
import {
|
||||
@@ -44,10 +44,9 @@ const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHead
|
||||
return computed_signature;
|
||||
};
|
||||
|
||||
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
|
||||
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
|
||||
const downloadLinkResponse = downloadLinkSchema.parse(response);
|
||||
const downloadLink = downloadLinkResponse.download_link;
|
||||
const getProxyDownloadLinkOfCalVideo = async (recordingId: string) => {
|
||||
const token = generateVideoToken(recordingId);
|
||||
const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`;
|
||||
return downloadLink;
|
||||
};
|
||||
|
||||
@@ -110,7 +109,7 @@ export async function postHandler(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
|
||||
const downloadLink = await getProxyDownloadLinkOfCalVideo(recording_id);
|
||||
|
||||
const teamId = await getTeamIdFromEventType({
|
||||
eventType: {
|
||||
@@ -192,7 +191,7 @@ export async function postHandler(request: NextRequest) {
|
||||
|
||||
const evt = await getCalendarEvent(booking);
|
||||
|
||||
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
|
||||
const recording = await getProxyDownloadLinkOfCalVideo(input.recordingId);
|
||||
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
|
||||
|
||||
await triggerTranscriptionGeneratedWebhook({
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
||||
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("@calcom/lib/videoClient", () => ({
|
||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/videoTokens", () => ({
|
||||
verifyVideoToken: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("GET /api/video/recording", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns 401 when token is missing", async () => {
|
||||
const request = new Request("http://example.com/api/video/recording");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.text()).toBe("Missing token");
|
||||
});
|
||||
|
||||
test("returns 401 when token is invalid", async () => {
|
||||
vi.mocked(verifyVideoToken).mockReturnValue({ valid: false });
|
||||
|
||||
const request = new Request("http://example.com/api/video/recording?token=invalid_token");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.text()).toBe("Invalid or expired token");
|
||||
});
|
||||
|
||||
test("returns 404 when recording is not found", async () => {
|
||||
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
|
||||
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue(undefined);
|
||||
|
||||
const request = new Request("http://example.com/api/video/recording?token=valid_token");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("Recording not found");
|
||||
});
|
||||
|
||||
test("redirects to download link when recording exists", async () => {
|
||||
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
|
||||
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
|
||||
download_link: "https://example.com/download",
|
||||
});
|
||||
|
||||
const request = new Request("http://example.com/api/video/recording?token=valid_token");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response instanceof NextResponse).toBe(true);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("https://example.com/download");
|
||||
});
|
||||
|
||||
test("returns 500 when getting recording fails", async () => {
|
||||
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
|
||||
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockRejectedValue(new Error("Failed to get recording"));
|
||||
|
||||
const request = new Request("http://example.com/api/video/recording?token=valid_token");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toBe("Failed to get recording");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
||||
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
return new Response("Missing token", { status: 401 });
|
||||
}
|
||||
|
||||
const verification = verifyVideoToken(token);
|
||||
if (!verification.valid || !verification.recordingId) {
|
||||
return new Response("Invalid or expired token", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const recordingLink = await getDownloadLinkOfCalVideoByRecordingId(verification.recordingId);
|
||||
if (!recordingLink) {
|
||||
return new Response("Recording not found", { status: 404 });
|
||||
}
|
||||
return NextResponse.redirect(recordingLink.download_link, { status: 302 });
|
||||
} catch (error) {
|
||||
console.error("Failed to get recording:", error);
|
||||
return new Response("Failed to get recording", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
|
||||
@@ -87,7 +87,9 @@ function buildParsedBooking(booking: BookingItemProps) {
|
||||
>)
|
||||
: null;
|
||||
|
||||
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
|
||||
return {
|
||||
...booking,
|
||||
eventType: bookingEventType,
|
||||
@@ -129,7 +131,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.markNoShow.useMutation({
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
// Invalidate and refetch the bookings query to update the UI
|
||||
@@ -936,10 +938,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.markNoShow.useMutation({
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
utils.viewer.bookings.invalidate();
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
@@ -1028,9 +1030,11 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
};
|
||||
});
|
||||
const { t } = useLocale();
|
||||
const noShowMutation = trpc.viewer.markNoShow.useMutation({
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t(data.message), "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
@@ -1131,7 +1135,8 @@ const NoShowAttendeesDialog = ({
|
||||
}))
|
||||
);
|
||||
|
||||
const noShowMutation = trpc.viewer.markNoShow.useMutation({
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
const newValue = data.attendees[0];
|
||||
setNoShowAttendees((old) =>
|
||||
@@ -1140,6 +1145,7 @@ const NoShowAttendeesDialog = ({
|
||||
)
|
||||
);
|
||||
showToast(t(data.message), "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
@@ -1170,7 +1176,7 @@ const NoShowAttendeesDialog = ({
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
<DialogFooter>
|
||||
<DialogFooter noSticky>
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -91,6 +91,11 @@ export function NotificationSoundHandler() {
|
||||
// Don't automatically initialize - wait for user interaction
|
||||
return () => {
|
||||
stopSound();
|
||||
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
audioContextRef.current = null;
|
||||
audioBufferRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const obtainNewUsernameChangeCondition = ({
|
||||
}: {
|
||||
userIsPremium: boolean;
|
||||
isNewUsernamePremium: boolean;
|
||||
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
|
||||
stripeCustomer: RouterOutputs["viewer"]["loggedInViewerRouter"]["stripeCustomer"] | undefined;
|
||||
}) => {
|
||||
if (!userIsPremium && isNewUsernamePremium) {
|
||||
return UsernameChangeStatusEnum.UPGRADE;
|
||||
@@ -72,7 +72,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const recentAttemptPaymentStatus = searchParams?.get("recentAttemptPaymentStatus");
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
||||
const { data: stripeCustomer } = trpc.viewer.loggedInViewerRouter.stripeCustomer.useQuery();
|
||||
const isCurrentUsernamePremium =
|
||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
||||
|
||||
@@ -94,6 +94,7 @@ const getEventTypes = async (userId: number, teamIds?: number[]) => {
|
||||
userId: true,
|
||||
destinationCalendar: true,
|
||||
bookingFields: true,
|
||||
calVideoSettings: true,
|
||||
});
|
||||
let eventTypeGroups: TEventTypeGroup[] | null = [];
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { GetServerSidePropsResult, GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -33,13 +32,6 @@ export async function getServerSideProps(
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
const user = session?.user;
|
||||
const app = await getAppWithMetadata({ slug: "routing-forms" });
|
||||
|
||||
if (!app) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getServerSideProps(context as AppGetServerSidePropsContext, prisma, user);
|
||||
|
||||
@@ -53,7 +45,7 @@ export async function getServerSideProps(
|
||||
|
||||
return {
|
||||
props: {
|
||||
appUrl: app.simplePath || `/apps/routing-forms`,
|
||||
appUrl: "/routing",
|
||||
...result.props,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { EmbedProps } from "app/WithEmbedSSR";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,11 +6,11 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -112,17 +111,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const caller = await createRouterCaller(publicViewerRouter);
|
||||
|
||||
const eventData = await caller.event({
|
||||
username: name,
|
||||
eventSlug: slug,
|
||||
isTeamEvent,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
const eventData = await EventRepository.getPublicEvent(
|
||||
{
|
||||
username: name,
|
||||
eventSlug: slug,
|
||||
isTeamEvent,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
},
|
||||
session?.user?.id
|
||||
);
|
||||
|
||||
if (!eventData) {
|
||||
return notFound;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { describe, afterEach, test, vi, beforeEach, beforeAll, expect } from "vi
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
|
||||
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -70,9 +70,9 @@ vi.mock("@calcom/app-store/dailyvideo/lib", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/videoClient", () => {
|
||||
vi.mock("@calcom/lib/videoTokens", () => {
|
||||
return {
|
||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||
generateVideoToken: vi.fn().mockReturnValue("MOCK_TOKEN"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -189,7 +189,6 @@ describe("Handler: /api/recorded-daily-video", () => {
|
||||
const bookingUid = "n5Wv3eHgconAED2j4gcVhP";
|
||||
const iCalUID = `${bookingUid}@Cal.com`;
|
||||
const subscriberUrl = "http://my-webhook.example.com";
|
||||
const recordingDownloadLink = "https://download-link.com";
|
||||
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const booker = getBooker({
|
||||
@@ -262,9 +261,6 @@ describe("Handler: /api/recorded-daily-video", () => {
|
||||
|
||||
vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID");
|
||||
vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK);
|
||||
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
|
||||
download_link: recordingDownloadLink,
|
||||
});
|
||||
|
||||
const { req } = createMocks({
|
||||
method: "POST",
|
||||
@@ -291,7 +287,7 @@ describe("Handler: /api/recorded-daily-video", () => {
|
||||
uid: bookingUid,
|
||||
downloadLinks: {
|
||||
transcription: TRANSCRIPTION_ACCESS_LINK.transcription,
|
||||
recording: recordingDownloadLink,
|
||||
recording: `${WEBAPP_URL}/api/video/recording?token=MOCK_TOKEN`,
|
||||
},
|
||||
organizer: {
|
||||
email: organizer.email,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useMeQuery from "./useMeQuery";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
export const useCurrentUserId = () => {
|
||||
const query = useMeQuery();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.viewer.me.get.useQuery(undefined, {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
export default useMeQuery;
|
||||
+19
-13
@@ -1,13 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
@@ -38,18 +38,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
if (!org) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
const session = await getServerSession({ req: context.req });
|
||||
const eventData = await EventRepository.getPublicEvent(
|
||||
{
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
},
|
||||
session?.user?.id
|
||||
);
|
||||
|
||||
const caller = await createRouterCaller(publicViewerRouter);
|
||||
|
||||
const eventData = await caller.event({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
if (!eventData || !org) {
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { getSafe } from "@calcom/lib/getSafe";
|
||||
@@ -59,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
slug: true,
|
||||
allowReschedulingPastBookings: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
@@ -109,18 +111,31 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
profileEnrichedBookingUser: enrichedBookingUser,
|
||||
});
|
||||
|
||||
const isForcedRescheduleForCancelledBooking = allowRescheduleForCancelledBooking;
|
||||
// If booking is already REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details.
|
||||
// If the booking is CANCELLED and allowRescheduleForCancelledBooking is false, we redirect the user to the original event link.
|
||||
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
|
||||
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
|
||||
if (
|
||||
isDisabledRescheduling ||
|
||||
(!allowRescheduleForCancelledBooking &&
|
||||
(booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED))
|
||||
) {
|
||||
// This comes from query param and thus is considered forced
|
||||
const canRescheduleCancelledBooking =
|
||||
isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
|
||||
if (isDisabledRescheduling) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: booking.status === BookingStatus.CANCELLED ? eventUrl : `/booking/${uid}`,
|
||||
destination: `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonRescheduleableBooking) {
|
||||
const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking;
|
||||
return {
|
||||
redirect: {
|
||||
destination: canReschedule ? eventUrl : `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
@@ -139,7 +154,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (isBookingInPast && !eventType.allowReschedulingPastBookings) {
|
||||
const destinationUrlSearchParams = new URLSearchParams();
|
||||
const responses = bookingSeat ? getSafe<string>(bookingSeat.data, ["responses"]) : booking.responses;
|
||||
const name = getSafe<string>(responses, ["name"]);
|
||||
const name = getFullName(getSafe<string | { firstName: string; lastName?: string }>(responses, ["name"]));
|
||||
const email = getSafe<string>(responses, ["email"]);
|
||||
|
||||
if (name) destinationUrlSearchParams.set("name", name);
|
||||
|
||||
@@ -21,6 +21,10 @@ const paramsSchema = z.object({
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
function hasApiV2RouteInEnv() {
|
||||
return Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
@@ -67,7 +71,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (booking?.status === BookingStatus.CANCELLED && !allowRescheduleForCancelledBooking) {
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
@@ -115,7 +123,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const useApiV2 = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -207,6 +216,7 @@ const getTeamWithEventsData = async (
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
|
||||
@@ -12,12 +12,62 @@ import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
||||
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
|
||||
import { isENVDev } from "@calcom/lib/env";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
type CalVideoSettings = {
|
||||
disableRecordingForGuests: boolean;
|
||||
disableRecordingForOrganizer: boolean;
|
||||
};
|
||||
|
||||
const shouldEnableRecordButton = ({
|
||||
hasTeamPlan,
|
||||
calVideoSettings,
|
||||
isOrganizer,
|
||||
}: {
|
||||
hasTeamPlan: boolean;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
isOrganizer: boolean;
|
||||
}) => {
|
||||
if (!hasTeamPlan) return false;
|
||||
if (!calVideoSettings) return true;
|
||||
|
||||
if (isOrganizer) {
|
||||
return !calVideoSettings.disableRecordingForOrganizer;
|
||||
}
|
||||
|
||||
return !calVideoSettings.disableRecordingForGuests;
|
||||
};
|
||||
|
||||
const checkIfUserIsHost = async ({
|
||||
booking,
|
||||
sessionUserId,
|
||||
}: {
|
||||
booking: {
|
||||
user: { id: number } | null;
|
||||
eventTypeId: number | undefined;
|
||||
};
|
||||
sessionUserId?: number;
|
||||
}) => {
|
||||
if (!sessionUserId) return false;
|
||||
|
||||
if (!booking.eventTypeId) {
|
||||
return booking.user?.id === sessionUserId;
|
||||
}
|
||||
|
||||
const eventType = await EventTypeRepository.findByIdWithUserAccess({
|
||||
id: booking.eventTypeId,
|
||||
userId: sessionUserId,
|
||||
});
|
||||
|
||||
// If eventType exists, it means user is either owner, host or user
|
||||
return !eventType;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { req } = context;
|
||||
|
||||
@@ -107,13 +157,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
});
|
||||
|
||||
const sessionUserId = !!session?.user?.impersonatedBy ? session.user.impersonatedBy.id : session?.user.id;
|
||||
const isOrganizer = await checkIfUserIsHost({
|
||||
booking: {
|
||||
eventTypeId: bookingObj.eventType?.id,
|
||||
user: bookingObj.user,
|
||||
},
|
||||
sessionUserId,
|
||||
});
|
||||
|
||||
// set meetingPassword for guests
|
||||
if (sessionUserId !== bookingObj.user?.id) {
|
||||
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
|
||||
videoReferencePassword,
|
||||
sessionUserId
|
||||
);
|
||||
if (!isOrganizer) {
|
||||
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
|
||||
meetingToken: videoReferencePassword,
|
||||
userId: sessionUserId,
|
||||
});
|
||||
|
||||
bookingObj.references.forEach((bookRef) => {
|
||||
bookRef.meetingPassword = guestMeetingPassword;
|
||||
@@ -141,6 +198,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
||||
: false;
|
||||
|
||||
const showRecordingButton = shouldEnableRecordButton({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
meetingUrl: videoReference.meetingUrl ?? "",
|
||||
@@ -161,6 +224,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
calVideoLogo,
|
||||
displayLogInOverlay,
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
showRecordingButton,
|
||||
rediectAttendeeToOnExit: isOrganizer
|
||||
? undefined
|
||||
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@ import { STEPS } from "~/apps/installation/[[...step]]/constants";
|
||||
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
|
||||
Pick<
|
||||
EventTypeModel,
|
||||
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
|
||||
| "metadata"
|
||||
| "schedulingType"
|
||||
| "slug"
|
||||
| "requiresConfirmation"
|
||||
| "position"
|
||||
| "destinationCalendar"
|
||||
| "calVideoSettings"
|
||||
> & {
|
||||
selected: boolean;
|
||||
locations: LocationFormValues["locations"];
|
||||
|
||||
@@ -9,10 +9,10 @@ import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function Authorize() {
|
||||
const { t } = useLocale();
|
||||
@@ -39,7 +39,8 @@ export default function Authorize() {
|
||||
}
|
||||
);
|
||||
|
||||
const { data, isPending: isPendingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
|
||||
const { data, isPending: isPendingProfiles } =
|
||||
trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery();
|
||||
|
||||
const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -21,14 +21,13 @@ 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";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
||||
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||
|
||||
@@ -115,6 +114,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
||||
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
||||
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
|
||||
|
||||
const { limit, offset } = useDataTable();
|
||||
|
||||
@@ -128,6 +128,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
@@ -214,6 +215,21 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
@@ -336,6 +352,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
||||
@@ -217,6 +217,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const internalNotes = await getInternalNotePresets(eventType.team?.id ?? eventType.parent?.teamId ?? null);
|
||||
|
||||
// Filter out organizer information if hideOrganizerEmail is true
|
||||
const sanitizedPreviousBooking =
|
||||
eventType.hideOrganizerEmail &&
|
||||
previousBooking &&
|
||||
previousBooking.rescheduledBy === bookingInfo.user?.email
|
||||
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
|
||||
: previousBooking;
|
||||
|
||||
return {
|
||||
props: {
|
||||
orgSlug: currentOrgDomain,
|
||||
@@ -227,7 +235,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||
bookingInfo,
|
||||
previousBooking,
|
||||
previousBooking: sanitizedPreviousBooking,
|
||||
paymentStatus: payment,
|
||||
...(tz && { tz }),
|
||||
userTimeFormat,
|
||||
|
||||
@@ -129,7 +129,9 @@ export default function Success(props: PageProps) {
|
||||
rescheduleLocation = bookingInfo.responses.location.optionValue;
|
||||
}
|
||||
|
||||
const parsedBookingMetadata = bookingMetadataSchema.parse(bookingInfo?.metadata || {});
|
||||
const parsed = bookingMetadataSchema.safeParse(bookingInfo?.metadata ?? null);
|
||||
const parsedBookingMetadata = parsed.success ? parsed.data : null;
|
||||
|
||||
const bookingWithParsedMetadata = {
|
||||
...bookingInfo,
|
||||
metadata: parsedBookingMetadata,
|
||||
|
||||
@@ -26,7 +26,7 @@ function ConnectAndJoin() {
|
||||
const session = useSession();
|
||||
const isUserPartOfOrg = session.status === "authenticated" && !!session.data.user?.org;
|
||||
|
||||
const mutation = trpc.viewer.connectAndJoin.useMutation({
|
||||
const mutation = trpc.viewer.loggedInViewerRouter.connectAndJoin.useMutation({
|
||||
onSuccess: (res) => {
|
||||
if (res.meetingUrl && !res.isBookingAlreadyAcceptedBySomeoneElse) {
|
||||
router.push(res.meetingUrl);
|
||||
|
||||
@@ -18,11 +18,13 @@ export default function Type({
|
||||
duration,
|
||||
hashedLink,
|
||||
durationConfig,
|
||||
eventData,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<BookingPageErrorBoundary>
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<Booker
|
||||
eventData={eventData}
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
bookingData={booking}
|
||||
|
||||
@@ -259,7 +259,7 @@ export const InfiniteEventTypeList = ({
|
||||
const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState<Record<string, number>>({});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.viewer.eventTypeOrder.useMutation({
|
||||
const mutation = trpc.viewer.loggedInViewerRouter.eventTypeOrder.useMutation({
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
|
||||
@@ -758,7 +758,7 @@ export const InfiniteEventTypeList = ({
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!isManagedEventType && (
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between rounded-b-lg px-4 py-2 transition">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import MembersView from "@calcom/features/ee/organizations/pages/members";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const MembersPage: React.FC = () => {
|
||||
export const MembersView = (props: UserListTableProps) => {
|
||||
const { t } = useLocale();
|
||||
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
|
||||
const canLoggedInUserSeeMembers =
|
||||
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
|
||||
|
||||
return (
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
heading={t("organization_members")}
|
||||
subtitle={t("organization_description")}
|
||||
headerClassName="hidden md:block"
|
||||
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
<MembersView />
|
||||
</Shell>
|
||||
<LicenseRequired>
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
|
||||
{!canLoggedInUserSeeMembers && (
|
||||
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
|
||||
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
|
||||
</div>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersPage;
|
||||
|
||||
+12
-1
@@ -12,13 +12,24 @@ import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary
|
||||
|
||||
export type Props = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
function Type({ slug, user, booking, isEmbed, isBrandingHidden, entity, eventTypeId, duration }: Props) {
|
||||
function Type({
|
||||
slug,
|
||||
user,
|
||||
booking,
|
||||
isEmbed,
|
||||
isBrandingHidden,
|
||||
entity,
|
||||
eventTypeId,
|
||||
duration,
|
||||
eventData,
|
||||
}: Props) {
|
||||
return (
|
||||
<BookingPageErrorBoundary>
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
eventData={eventData}
|
||||
bookingData={booking}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
|
||||
@@ -121,7 +121,7 @@ const ProfileView = () => {
|
||||
}
|
||||
},
|
||||
});
|
||||
const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({
|
||||
const unlinkConnectedAccountMutation = trpc.viewer.loggedInViewerRouter.unlinkConnectedAccount.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
showToast(t(res.message), "success");
|
||||
utils.viewer.me.invalidate();
|
||||
@@ -131,7 +131,7 @@ const ProfileView = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({
|
||||
const addSecondaryEmailMutation = trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
|
||||
onSuccess: (res) => {
|
||||
setShowSecondaryEmailModalOpen(false);
|
||||
setNewlyAddedSecondaryEmail(res?.data?.email);
|
||||
|
||||
@@ -61,7 +61,16 @@ const BUTTONS = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CalAiTranscribe = () => {
|
||||
export type DailyCustomTrayButtonVisualState = "default" | "sidebar-open" | "active";
|
||||
|
||||
export interface DailyCustomTrayButton {
|
||||
iconPath: string;
|
||||
iconPathDarkMode?: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
visualState?: DailyCustomTrayButtonVisualState;
|
||||
}
|
||||
export const CalAiTranscribe = ({ showRecordingButton }: { showRecordingButton: boolean }) => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -73,6 +82,23 @@ export const CalAiTranscribe = () => {
|
||||
const transcription = useTranscription();
|
||||
const recording = useRecording();
|
||||
|
||||
const updateCustomTrayButtons = ({
|
||||
recording,
|
||||
transcription,
|
||||
}: {
|
||||
recording: DailyCustomTrayButton;
|
||||
transcription: DailyCustomTrayButton;
|
||||
}) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
...(showRecordingButton
|
||||
? {
|
||||
recording,
|
||||
}
|
||||
: {}),
|
||||
transcription,
|
||||
});
|
||||
};
|
||||
|
||||
useDailyEvent(
|
||||
"app-message",
|
||||
useCallback((ev) => {
|
||||
@@ -82,28 +108,28 @@ export const CalAiTranscribe = () => {
|
||||
);
|
||||
|
||||
useDailyEvent("transcription-started", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("recording-started", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("transcription-stopped", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("recording-stopped", (ev) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
@@ -111,7 +137,7 @@ export const CalAiTranscribe = () => {
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording?.isRecording) {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
@@ -119,7 +145,7 @@ export const CalAiTranscribe = () => {
|
||||
});
|
||||
await daily?.stopRecording();
|
||||
} else {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
@@ -135,16 +161,17 @@ export const CalAiTranscribe = () => {
|
||||
|
||||
const toggleTranscription = async () => {
|
||||
if (transcription?.isTranscribing) {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
|
||||
});
|
||||
daily?.stopTranscription();
|
||||
} else {
|
||||
daily?.updateCustomTrayButtons({
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
|
||||
});
|
||||
|
||||
daily?.startTranscription();
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user