Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d15490d5aa | ||
|
|
ba6e547f6c | ||
|
|
2493315f92 | ||
|
|
ec117d4034 | ||
|
|
646058b8a1 | ||
|
|
06c359ac9b | ||
|
|
0d96d9c44c | ||
|
|
e5f8d4a1ac | ||
|
|
19c72323b8 | ||
|
|
f19b2804b6 | ||
|
|
f387fdb166 | ||
|
|
ec8f9d5f7a | ||
|
|
6cb3bdc567 | ||
|
|
d574840c3f | ||
|
|
6a85d0e10b | ||
|
|
816df3704e | ||
|
|
badd9a5698 | ||
|
|
4e02fe4251 | ||
|
|
01b9780938 | ||
|
|
6be144f95f | ||
|
|
37dd6d888b | ||
|
|
f5ffee0a20 | ||
|
|
6cd51e0a8d | ||
|
|
80fad2aab9 | ||
|
|
1ccfc65766 | ||
|
|
86918b03cf | ||
|
|
a5b684af1f | ||
|
|
8889e8566c | ||
|
|
fab364f084 | ||
|
|
5bc9d71a88 | ||
|
|
2b5ea649d4 | ||
|
|
0802ebb17b | ||
|
|
29b8d68f45 | ||
|
|
6994cac5e0 | ||
|
|
df9b72583d | ||
|
|
553eab59e6 | ||
|
|
8d675fd8b6 | ||
|
|
a5639a8290 | ||
|
|
44d9c4bcc5 | ||
|
|
1b586560f8 | ||
|
|
e082cc7d3d | ||
|
|
d411e70ea3 | ||
|
|
8dffa4d371 | ||
|
|
b7bf6e80e6 | ||
|
|
2a93e661f9 | ||
|
|
bfb51b82b2 | ||
|
|
f50be6c08b | ||
|
|
df75df7008 | ||
|
|
deb55c0536 | ||
|
|
7fc11ff341 | ||
|
|
1a70f83d34 | ||
|
|
39077a08f1 | ||
|
|
9a671f9063 | ||
|
|
3bc9fa11d0 | ||
|
|
7e699ab810 |
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ SENTRY_AUTH_TOKEN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_SAMPLE_RATE=
|
||||
SENTRY_TRACES_SAMPLE_RATE=
|
||||
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
|
||||
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
|
||||
@@ -187,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=
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/**/* @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
||||
jobs:
|
||||
release:
|
||||
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||
permissions:
|
||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup node, yarn and install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: yarn changesets-version
|
||||
publish: yarn changesets-release
|
||||
commit: "chore: version packages" # commit message
|
||||
title: "chore: version packages" # PR title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
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
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@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.
|
||||
@@ -646,6 +646,14 @@ following
|
||||
15. Create a verify service
|
||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||
|
||||
## Changesets
|
||||
|
||||
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||
|
||||
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||
1. Follow semantic versioning when using changesets.
|
||||
2. Mark breaking changes using `❗️Breaking change`
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
@@ -38,7 +37,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.197",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let eventTypeId: number;
|
||||
let seatedEvent: EventType;
|
||||
let eventTypeWithBookingFields: EventType;
|
||||
let eventTypeWithBookingFielsCustom: EventType;
|
||||
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
user.id
|
||||
);
|
||||
|
||||
eventTypeWithBookingFielsCustom = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "test booking field types",
|
||||
slug: "test-booking-field-types",
|
||||
length: 30,
|
||||
bookingFields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "name",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
variant: "fullName",
|
||||
editable: "system",
|
||||
required: true,
|
||||
defaultLabel: "your_name",
|
||||
variantsConfig: {
|
||||
variants: {
|
||||
fullName: {
|
||||
fields: [
|
||||
{
|
||||
name: "fullName",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
firstAndLastName: {
|
||||
fields: [
|
||||
{
|
||||
name: "firstName",
|
||||
type: "text",
|
||||
label: "",
|
||||
required: true,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
name: "lastName",
|
||||
type: "text",
|
||||
label: "",
|
||||
required: false,
|
||||
placeholder: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: true,
|
||||
defaultLabel: "email_address",
|
||||
},
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
defaultLabel: "location",
|
||||
getOptionsAt: "locations",
|
||||
optionsInputs: {
|
||||
phone: { type: "phone", required: true, placeholder: "" },
|
||||
somewhereElse: { type: "text", required: true, placeholder: "" },
|
||||
attendeeInPerson: { type: "address", required: true, placeholder: "" },
|
||||
},
|
||||
hideWhenJustOneOption: true,
|
||||
},
|
||||
{
|
||||
name: "test-phone",
|
||||
type: "phone",
|
||||
label: "test-phone",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-address",
|
||||
type: "address",
|
||||
label: "test-address",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-text",
|
||||
type: "text",
|
||||
label: "test-text",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-number",
|
||||
type: "number",
|
||||
label: "test-number",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-textarea",
|
||||
type: "textarea",
|
||||
label: "test-textarea",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-boolean",
|
||||
type: "boolean",
|
||||
label: "test-boolean",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
labelAsSafeHtml: "<p>test-boolean</p>\n",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-url",
|
||||
type: "url",
|
||||
label: "test-url",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
placeholder: "",
|
||||
labelAsSafeHtml: "<p>test-url</p>\n",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-multiemail",
|
||||
type: "multiemail",
|
||||
label: "test-multiemail",
|
||||
hidden: false,
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-multiselect",
|
||||
type: "multiselect",
|
||||
label: "test-multiselect",
|
||||
hidden: false,
|
||||
options: [
|
||||
{
|
||||
label: "apple",
|
||||
value: "apple",
|
||||
},
|
||||
{
|
||||
label: "orange",
|
||||
value: "orange",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-checkbox",
|
||||
type: "checkbox",
|
||||
label: "test-checkbox",
|
||||
hidden: false,
|
||||
options: [
|
||||
{
|
||||
label: "blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
label: "red",
|
||||
value: "red",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-radio",
|
||||
type: "radio",
|
||||
label: "test-radio",
|
||||
hidden: false,
|
||||
options: [
|
||||
{
|
||||
label: "pineapple",
|
||||
value: "pineapple",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-select",
|
||||
type: "select",
|
||||
label: "test-select",
|
||||
options: [
|
||||
{
|
||||
label: "water",
|
||||
value: "water",
|
||||
},
|
||||
{
|
||||
label: "juice",
|
||||
value: "juice",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: true,
|
||||
placeholder: "",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
hidden: true,
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: true,
|
||||
defaultLabel: "what_is_this_meeting_about",
|
||||
defaultPlaceholder: "",
|
||||
},
|
||||
{
|
||||
name: "notes",
|
||||
type: "textarea",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "additional_notes",
|
||||
defaultPlaceholder: "share_additional_notes",
|
||||
},
|
||||
{
|
||||
name: "guests",
|
||||
type: "multiemail",
|
||||
hidden: false,
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "additional_guests",
|
||||
defaultPlaceholder: "email",
|
||||
},
|
||||
{
|
||||
name: "rescheduleReason",
|
||||
type: "textarea",
|
||||
views: [{ id: "reschedule", label: "Reschedule View" }],
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "reason_for_reschedule",
|
||||
defaultPlaceholder: "reschedule_placeholder",
|
||||
},
|
||||
],
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
bookingWithSplitName = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
@@ -543,6 +851,318 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Booking Field Type Validation", () => {
|
||||
const basePayload = {
|
||||
start: "2025-06-19T11:00:00.000Z",
|
||||
attendee: {
|
||||
name: "Charlie TypeTest",
|
||||
email: "charlie.typetest@example.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "en",
|
||||
},
|
||||
};
|
||||
|
||||
it("should reject with 400 if 'test-phone' (phone) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-phone": 12345,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-phone'. Expected type string (compatible with field type 'phone'), but received number."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-address' (address) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-address": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-address'. Expected type string (compatible with field type 'address'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-text' (text) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-text": 123,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-text'. Expected type string (compatible with field type 'text'), but received number."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-number' (number) is not a number", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-number": "123",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-number'. Expected type number (compatible with field type 'number'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-textarea' (textarea) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-textarea": { text: "invalid" },
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-textarea'. Expected type string (compatible with field type 'textarea'), but received object."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-boolean' (boolean) is not a boolean", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-boolean": "true",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-boolean'. Expected type boolean (compatible with field type 'boolean'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-url' (url) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-url": 12345,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-url'. Expected type string (compatible with field type 'url'), but received number."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-multiemail' (multiemail) is not an array", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-multiemail": "not-an-array@example.com",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-multiemail'. Expected type array (compatible with field type 'multiemail'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-multiselect' (multiselect) is not an array", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-multiselect": "not-an-array",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-multiselect'. Expected type array (compatible with field type 'multiselect'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-checkbox' (checkbox) is not an array", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-checkbox": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-checkbox'. Expected type array (compatible with field type 'checkbox'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-radio' (radio) is not a string or number", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-radio": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-radio'. Expected type string or number (compatible with field type 'radio'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-select' (select) is not a string or number", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-select": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-select'. Expected type string or number (compatible with field type 'select'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-select field with invalid option", async () => {
|
||||
const fieldName = "test-select";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: "INVALID_DRINK",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`Invalid option 'INVALID_DRINK' for booking field '${fieldName}'. Allowed options are: water, juice.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-radio field with invalid option", async () => {
|
||||
const fieldName = "test-radio";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: "INVALID_FRUIT_FOR_RADIO",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`Invalid option 'INVALID_FRUIT_FOR_RADIO' for booking field '${fieldName}'. Allowed options are: pineapple.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-multiselect field with an invalid option", async () => {
|
||||
const fieldName = "test-multiselect";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: ["apple", "INVALID_FRUIT"],
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`One or more invalid options for booking field '${fieldName}'. Allowed options are: apple, orange.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-checkbox field with invalid options", async () => {
|
||||
const fieldName = "test-checkbox";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: ["blue", "INVALID_COLOR"],
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import { EventType, User, Workflow } from "@prisma/client";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
|
||||
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
VERSION_2024_08_13,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
|
||||
let workflowRepositoryFixture: WorkflowRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
let eventTypeId: number;
|
||||
let eventType: EventType;
|
||||
let eventTypeWithAttendeeSmsReminder: EventType;
|
||||
let workflow: Workflow;
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
|
||||
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `user-bookings-organization-${randomString()}`,
|
||||
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
rating: 10,
|
||||
});
|
||||
|
||||
workflow = await workflowRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
trigger: "BEFORE_EVENT",
|
||||
time: 24,
|
||||
timeUnit: "HOUR",
|
||||
position: 0,
|
||||
isActiveOnAll: false,
|
||||
name: "Attendee SMS Reminder",
|
||||
steps: {
|
||||
create: {
|
||||
stepNumber: 1,
|
||||
action: "SMS_ATTENDEE",
|
||||
sendTo: null,
|
||||
reminderBody:
|
||||
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
|
||||
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
|
||||
template: "REMINDER",
|
||||
numberRequired: true,
|
||||
sender: "Cal",
|
||||
numberVerificationPending: false,
|
||||
includeCalendarEvent: false,
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "event with attendee sms reminder",
|
||||
slug: "event-with-attendee-sms-reminder",
|
||||
length: 15,
|
||||
bookingFields: [
|
||||
{
|
||||
name: "smsReminderNumber",
|
||||
type: "phone",
|
||||
sources: [
|
||||
{
|
||||
id: String(workflow.id),
|
||||
type: "workflow",
|
||||
label: "Workflow",
|
||||
editUrl: `/workflows/${workflow.id}`,
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "system",
|
||||
required: true,
|
||||
defaultLabel: "number_text_notifications",
|
||||
defaultPlaceholder: "enter_phone_number",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
disableStandardEmails: {
|
||||
all: {
|
||||
attendee: true,
|
||||
host: true,
|
||||
},
|
||||
confirmation: {
|
||||
host: true,
|
||||
attendee: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
create: {
|
||||
workflow: {
|
||||
connect: {
|
||||
id: workflow.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -1668,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
describe("book by username and event type slug", () => {
|
||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
attendee: {
|
||||
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
@@ -2227,6 +2314,91 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("create booking with attendee sms reminder", () => {
|
||||
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should be able create a booking if attendee sms reminder is provided", async () => {
|
||||
const phoneNumber = "+37122222222";
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
phoneNumber,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
|
||||
expect(data.duration).toEqual(15);
|
||||
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
phoneNumber: body.attendee.phoneNumber,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||
smsReminderNumber: body.attendee.phoneNumber,
|
||||
});
|
||||
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
|
||||
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
|
||||
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
|
||||
expect(dbWorkflowReminder?.method).toEqual("SMS");
|
||||
} else {
|
||||
throw new Error("Invalid response data");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -59,13 +60,17 @@ type CreatedBooking = {
|
||||
start: string;
|
||||
};
|
||||
|
||||
const eventTypeBookingFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
editable: z.string(),
|
||||
});
|
||||
const eventTypeBookingFieldSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
editable: z.string(),
|
||||
type: z.string(),
|
||||
options: z.array(z.object({ value: z.string() })).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
|
||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||
|
||||
@@ -171,36 +176,187 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
||||
const bookingFieldsResponses: Record<string, unknown> = {
|
||||
...body.bookingFieldsResponses,
|
||||
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||
smsReminderNumber: body.attendee.phoneNumber,
|
||||
};
|
||||
if (!eventType?.bookingFields) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
|
||||
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
|
||||
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
|
||||
const eventTypeBookingFields = eventTypeBookingFieldsSchema
|
||||
.parse(eventType.bookingFields)
|
||||
.filter((field) => !field.editable.startsWith("system"));
|
||||
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
|
||||
|
||||
if (!eventTypeBookingFields.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const field of eventTypeBookingFields) {
|
||||
if (field.required && !(field.name in bookingFields)) {
|
||||
if (field.name === "attendeePhoneNumber") {
|
||||
for (const eventTypeBookingField of eventTypeBookingFields) {
|
||||
if (
|
||||
eventTypeBookingField.required &&
|
||||
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
|
||||
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
|
||||
) {
|
||||
if (
|
||||
eventTypeBookingField.name === "attendeePhoneNumber" ||
|
||||
eventTypeBookingField.name === "smsReminderNumber"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
|
||||
);
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||
`Missing required booking field response: ${eventTypeBookingField.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||
);
|
||||
}
|
||||
|
||||
const bookingFieldResponseValue = bookingFieldsResponses[eventTypeBookingField.name];
|
||||
if (bookingFieldResponseValue !== undefined) {
|
||||
const bookingFieldResponseValueType = typeof bookingFieldResponseValue;
|
||||
let expectedBookingFieldResponseValueType = "";
|
||||
let isValidType = false;
|
||||
const eventTypeBookingFieldType = eventTypeBookingField.type;
|
||||
|
||||
switch (eventTypeBookingFieldType) {
|
||||
case "phone":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "address":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "text":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "number":
|
||||
expectedBookingFieldResponseValueType = "number";
|
||||
isValidType = bookingFieldResponseValueType === "number";
|
||||
break;
|
||||
case "textarea":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "multiemail":
|
||||
expectedBookingFieldResponseValueType = "array";
|
||||
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||
break;
|
||||
case "boolean":
|
||||
expectedBookingFieldResponseValueType = "boolean";
|
||||
isValidType = bookingFieldResponseValueType === "boolean";
|
||||
break;
|
||||
case "select":
|
||||
expectedBookingFieldResponseValueType = "string or number";
|
||||
isValidType =
|
||||
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValue = bookingFieldResponseValue as string | number;
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option '${submittedValue}' for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "multiselect":
|
||||
expectedBookingFieldResponseValueType = "array";
|
||||
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`One or more invalid options for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "checkbox":
|
||||
expectedBookingFieldResponseValueType = "array";
|
||||
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`One or more invalid options for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "radio":
|
||||
expectedBookingFieldResponseValueType = "string or number";
|
||||
isValidType =
|
||||
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValue = bookingFieldResponseValue as string | number;
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option '${submittedValue}' for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "url":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
default:
|
||||
// note(Lauris): by default pass the field if we have a missing "case" in the switch
|
||||
isValidType = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isValidType) {
|
||||
throw new BadRequestException(
|
||||
`Invalid type for booking field '${eventTypeBookingField.name}'. Expected type ${expectedBookingFieldResponseValueType} (compatible with field type '${eventTypeBookingFieldType}'), but received ${bookingFieldResponseValueType}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isValidSingleOptionValue(
|
||||
bookingFieldResponseValue: string | number,
|
||||
eventTypeBookingFieldOptions: string[]
|
||||
): boolean {
|
||||
if (eventTypeBookingFieldOptions.length === 0) {
|
||||
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||
return true;
|
||||
}
|
||||
return eventTypeBookingFieldOptions.some((val) => val === String(bookingFieldResponseValue));
|
||||
}
|
||||
|
||||
private areValidMultipleOptionValues(
|
||||
bookingFieldResponseValues: (string | number)[],
|
||||
eventTypeBookingFieldOptions: string[]
|
||||
): boolean {
|
||||
if (eventTypeBookingFieldOptions.length === 0) {
|
||||
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||
return true;
|
||||
}
|
||||
return bookingFieldResponseValues.every((submittedVal) =>
|
||||
eventTypeBookingFieldOptions.some((allowedVal) => allowedVal === String(submittedVal))
|
||||
);
|
||||
}
|
||||
|
||||
async createInstantBooking(
|
||||
request: Request,
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
@@ -594,6 +750,20 @@ export class BookingsService_2024_08_13 {
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import {
|
||||
eventTypeBookingFieldsSchema,
|
||||
EventTypeWithOwnerAndTeam,
|
||||
} from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingDataSchema,
|
||||
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||
|
||||
const needsSmsReminderNumber = eventType.bookingFields
|
||||
? eventTypeBookingFieldsSchema
|
||||
.parse(eventType.bookingFields)
|
||||
.find((field) => field.name === "smsReminderNumber")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
name: inputBooking.attendee.name,
|
||||
email: attendeeEmail ?? "",
|
||||
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
||||
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
|
||||
guests,
|
||||
location,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib";
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
|
||||
|
||||
type BaseEventType = {
|
||||
length: number;
|
||||
|
||||
+62
-2
@@ -1428,7 +1428,6 @@ describe("Event types Endpoints", () => {
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
@@ -1558,7 +1557,6 @@ describe("Event types Endpoints", () => {
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
@@ -1783,6 +1781,68 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating event type with input of another event type output", () => {
|
||||
let firstCreatedEventType: EventTypeOutput_2024_06_14;
|
||||
let secondCreatedEventType: EventTypeOutput_2024_06_14;
|
||||
|
||||
it("should create first event type", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "first created coding class",
|
||||
slug: "first-created-coding-class",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "address",
|
||||
address: "via volturno 10, Roma",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
firstCreatedEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create second event type using first as input", async () => {
|
||||
const body = {
|
||||
...firstCreatedEventType,
|
||||
title: "second created coding class",
|
||||
slug: "second-created-coding-class",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
secondCreatedEventType = responseBody.data;
|
||||
|
||||
const { id, title, slug, ...restFirst } = firstCreatedEventType;
|
||||
const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType;
|
||||
expect(restFirst).toEqual(restSecond);
|
||||
expect(id2).not.toEqual(id);
|
||||
expect(title2).not.toEqual(title);
|
||||
expect(slug2).not.toEqual(slug);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+4
-2
@@ -224,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
||||
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
||||
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
|
||||
const systemCustomLocationField = systemCustomFields?.find((field) => field.name === "location");
|
||||
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
|
||||
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
||||
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
||||
@@ -233,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
||||
systemCustomNameField || systemBeforeFieldName,
|
||||
systemCustomEmailField || systemBeforeFieldEmail,
|
||||
systemBeforeFieldLocation,
|
||||
systemCustomLocationField || systemBeforeFieldLocation,
|
||||
];
|
||||
|
||||
const defaultFieldsAfter = [
|
||||
@@ -274,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
field.name !== "title" &&
|
||||
field.name !== "notes" &&
|
||||
field.name !== "guests" &&
|
||||
field.name !== "rescheduleReason"
|
||||
field.name !== "rescheduleReason" &&
|
||||
field.name !== "location"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
+86
@@ -685,6 +685,92 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
describe("routingFormResponseId and _isDryRun validation", () => {
|
||||
const baseUrl = "/api/v2/slots/available";
|
||||
const baseParams = {
|
||||
startTime: "2050-09-05",
|
||||
endTime: "2050-09-10",
|
||||
};
|
||||
|
||||
it("should allow routingFormResponseId=0 in dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 0,
|
||||
_isDryRun: true,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=1 in dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 1,
|
||||
_isDryRun: true,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be 0 for dry run"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow routingFormResponseId=1 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 1,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=0 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 0,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=-1 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: -1,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
@@ -157,6 +157,7 @@ export class SlotsController_2024_04_15 {
|
||||
@Query() query: GetAvailableSlotsInput_2024_04_15,
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
|
||||
try {
|
||||
const isTeamEvent =
|
||||
query.isTeamEvent === undefined
|
||||
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
|
||||
@@ -171,19 +172,29 @@ export class SlotsController_2024_04_15 {
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat,
|
||||
query.timeZone
|
||||
);
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat,
|
||||
query.timeZone
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
slots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
return {
|
||||
data: {
|
||||
slots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid time range given")) {
|
||||
throw new BadRequestException(
|
||||
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export class SlotsService_2024_04_15 {
|
||||
|
||||
let shouldReserveSlot = true;
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid);
|
||||
const bookingWithAttendees = input.bookingUid
|
||||
? await this.slotsRepo.getBookingWithAttendees(input.bookingUid)
|
||||
: undefined;
|
||||
const bookingAttendeesLength = bookingWithAttendees?.attendees?.length;
|
||||
if (bookingAttendeesLength) {
|
||||
const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength;
|
||||
|
||||
@@ -82,7 +82,7 @@ export class SlotsController_2024_09_04 {
|
||||
Optional parameters are:
|
||||
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
|
||||
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
|
||||
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
`,
|
||||
})
|
||||
@ApiQuery({
|
||||
@@ -99,7 +99,7 @@ export class SlotsController_2024_09_04 {
|
||||
example: "60",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "slotFormat",
|
||||
name: "format",
|
||||
required: false,
|
||||
description:
|
||||
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
|
||||
|
||||
@@ -42,24 +42,33 @@ export class SlotsService_2024_09_04 {
|
||||
) {}
|
||||
|
||||
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
try {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
const formatted = await this.slotsOutputService.getAvailableSlots(
|
||||
availableSlots,
|
||||
queryTransformed.eventTypeId,
|
||||
queryTransformed.duration,
|
||||
query.format,
|
||||
queryTransformed.timeZone
|
||||
);
|
||||
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
const formatted = await this.slotsOutputService.getAvailableSlots(
|
||||
availableSlots,
|
||||
queryTransformed.eventTypeId,
|
||||
queryTransformed.duration,
|
||||
query.format,
|
||||
queryTransformed.timeZone
|
||||
);
|
||||
|
||||
return formatted;
|
||||
return formatted;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid time range given")) {
|
||||
throw new BadRequestException(
|
||||
"Invalid time range given - check the 'start' and 'end' query parameters."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { PlatformAuthorizationToken } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@Injectable()
|
||||
export class TokensRepository {
|
||||
@@ -50,31 +51,18 @@ export class TokensRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
|
||||
if (deleteOld) {
|
||||
try {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
secret: this.jwtService.signAccessToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
@@ -82,7 +70,12 @@ export class TokensRepository {
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
@@ -98,6 +91,57 @@ export class TokensRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async forceRefreshOAuthTokens(clientId: string, ownerId: number) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
|
||||
const [_deletedAccessToken, _deletedRefreshToken, accessToken, refreshToken] =
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessTokenExpiryDate(accessTokenSecret: string) {
|
||||
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
|
||||
where: {
|
||||
@@ -140,6 +184,7 @@ export class TokensRepository {
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
@@ -155,6 +200,7 @@ export class TokensRepository {
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
|
||||
@@ -10506,7 +10506,7 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||
"summary": "Find out when is an event type ready to be booked.",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10583,7 +10583,7 @@
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"name": "slotFormat",
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
|
||||
@@ -14546,6 +14546,22 @@
|
||||
"slug"
|
||||
]
|
||||
},
|
||||
"LocationDefaultFieldInput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"example": "location",
|
||||
"description": "only allowed value for type is `location`. This booking field is displayed only when event type has 2 or more locations in order to allow person doing the booking pick the location."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"slug"
|
||||
]
|
||||
},
|
||||
"NotesDefaultFieldInput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14759,6 +14775,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -15350,6 +15369,9 @@
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15366,11 +15388,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "rescheduleReason",
|
||||
"description": "only allowed value for type is `rescheduleReason`",
|
||||
@@ -15416,11 +15438,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "title",
|
||||
"description": "only allowed value for type is `title`",
|
||||
@@ -15466,11 +15488,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "notes",
|
||||
"description": "only allowed value for type is `notes`",
|
||||
@@ -15516,11 +15538,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "guests",
|
||||
"description": "only allowed value for type is `guests`",
|
||||
@@ -16749,6 +16771,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -18563,6 +18588,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -19421,6 +19449,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
export class WorkflowReminderRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async getByBookingUid(uid: string) {
|
||||
return this.prismaReadClient.workflowReminder.findFirst({ where: { bookingUid: uid } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class WorkflowRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async create(data: Prisma.WorkflowCreateInput) {
|
||||
return this.prismaWriteClient.workflow.create({ data });
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
return this.prismaWriteClient.workflow.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
|
||||
@@ -13,6 +16,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
|
||||
const locale = props.eventData?.interfaceLanguage;
|
||||
if (locale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(locale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
<TypePage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <TypePage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -55,6 +57,17 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
|
||||
const locale = props.eventData?.interfaceLanguage;
|
||||
if (locale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(locale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
<LegacyPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <LegacyPage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
@@ -17,8 +20,32 @@ export type ClientPageProps = UserTypePageProps | TeamTypePageProps;
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
|
||||
return <UserTypePage {...(props as UserTypePageProps)} />;
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
const ns = "common";
|
||||
let translations;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
translations = await loadTranslations(eventLocale, ns);
|
||||
}
|
||||
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
);
|
||||
}
|
||||
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
@@ -63,10 +65,32 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return <TeamTypePage {...(props as TeamTypePageProps)} />;
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
const ns = "common";
|
||||
let translations;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
translations = await loadTranslations(eventLocale, ns);
|
||||
}
|
||||
return <UserTypePage {...(props as UserTypePageProps)} />;
|
||||
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
);
|
||||
}
|
||||
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
|
||||
@@ -12,6 +15,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<TypePage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <TypePage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
@@ -54,6 +56,18 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<LegacyPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <LegacyPage {...props} />;
|
||||
};
|
||||
export default ServerPage;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import OldPage from "~/bookings/views/bookings-single-view";
|
||||
@@ -15,6 +18,18 @@ const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getEmbedData(context);
|
||||
|
||||
const eventLocale = props.eventType?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<OldPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <OldPage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
@@ -36,6 +38,18 @@ const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: _PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
|
||||
const eventLocale = props.eventType?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<OldPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <OldPage {...props} />;
|
||||
};
|
||||
export default ServerPage;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||
|
||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsPage from "~/insights/insights-view";
|
||||
|
||||
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <InsightsPage />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
|
||||
|
||||
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <InsightsVirtualQueuesPage />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsRoutingPage from "~/insights/insights-routing-view";
|
||||
|
||||
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <InsightsRoutingPage />;
|
||||
}
|
||||
|
||||
+25
-10
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
@@ -27,6 +29,8 @@ import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { VerticalTabItem } from "@calcom/ui/components/navigation";
|
||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type TeamFeatures = Record<number, Record<keyof AppFlags, boolean>> | null;
|
||||
|
||||
const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
const tabs: VerticalTabItemProps[] = [
|
||||
{
|
||||
@@ -272,6 +276,7 @@ interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
teamFeatures?: TeamFeatures;
|
||||
}
|
||||
|
||||
const TeamListCollapsible = () => {
|
||||
@@ -438,6 +443,7 @@ const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
teamFeatures,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
@@ -449,11 +455,15 @@ const SettingsSidebarContainer = ({
|
||||
}[]
|
||||
>();
|
||||
const session = useSession();
|
||||
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
|
||||
const isDelegationCredentialEnabled = useIsFeatureEnabledForTeam({
|
||||
teamFeatures,
|
||||
teamId: session.data?.user?.org?.id,
|
||||
feature: "delegation-credential",
|
||||
});
|
||||
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
||||
isDelegationCredentialEnabled,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
@@ -727,9 +737,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
teamFeatures?: TeamFeatures;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
||||
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
@@ -761,6 +772,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
||||
<SidebarContainerElement
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
teamFeatures={teamFeatures}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
@@ -778,10 +790,18 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
teamFeatures?: TeamFeatures;
|
||||
};
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
teamFeatures,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -797,13 +817,8 @@ const SidebarContainerElement = ({
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
teamFeatures={teamFeatures}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -16,9 +18,22 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
let teamFeatures: Record<number, Record<keyof AppFlags, boolean>> | null = null;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (session?.user.org) {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const features = await featuresRepository.getTeamFeatures(session.user.org.id);
|
||||
if (features) {
|
||||
teamFeatures = {
|
||||
[session.user.org.id]: features,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} />
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +1,10 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import MembersPage from "~/members/members-view";
|
||||
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
@@ -11,8 +15,20 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/organizations/${(await params).id}/members`
|
||||
);
|
||||
|
||||
const ServerPageWrapper = () => {
|
||||
return <MembersPage />;
|
||||
const ServerPageWrapper = async () => {
|
||||
const [orgCaller, attributesCaller] = await Promise.all([
|
||||
createRouterCaller(viewerOrganizationsRouter),
|
||||
createRouterCaller(attributesRouter),
|
||||
]);
|
||||
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
attributesCaller.list(),
|
||||
]);
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
|
||||
@@ -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,10 @@
|
||||
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 { 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 { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -15,17 +16,20 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const [orgCaller, attributesCaller] = await Promise.all([
|
||||
createRouterCaller(viewerOrganizationsRouter),
|
||||
createRouterCaller(attributesRouter),
|
||||
]);
|
||||
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
attributesCaller.list(),
|
||||
]);
|
||||
|
||||
const children = (
|
||||
<SettingsHeader
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
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,9 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
|
||||
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -11,8 +14,26 @@ export const generateMetadata = async () =>
|
||||
"/settings/platform/members"
|
||||
);
|
||||
|
||||
const ServerPageWrapper = () => {
|
||||
return <PlatformMembersView />;
|
||||
const ServerPageWrapper = async () => {
|
||||
const [orgCaller, attributesCaller] = await Promise.all([
|
||||
createRouterCaller(viewerOrganizationsRouter),
|
||||
createRouterCaller(attributesRouter),
|
||||
]);
|
||||
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
attributesCaller.list(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PlatformMembersView
|
||||
org={org}
|
||||
teams={teams}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
attributes={attributes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type CustomI18nContextType = {
|
||||
translations: Record<string, string>;
|
||||
ns: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const CustomI18nContext = createContext<CustomI18nContextType | null>(null);
|
||||
|
||||
export function CustomI18nProvider({
|
||||
children,
|
||||
translations,
|
||||
locale,
|
||||
ns,
|
||||
}: CustomI18nContextType & {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Memoize the value to prevent re-renders unless the data changes
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
translations,
|
||||
locale,
|
||||
ns,
|
||||
}),
|
||||
[locale, ns]
|
||||
);
|
||||
|
||||
return <CustomI18nContext.Provider value={value}>{children}</CustomI18nContext.Provider>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -5,7 +5,8 @@ import { NextResponse } from "next/server";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -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,276 @@
|
||||
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 } 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 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a Selected Calendar even if the user's primary calendar ID does not match their email address", 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);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "notuser@example.com", 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,191 @@ const validateRequest = (req: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateSelectedCalendars() {
|
||||
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;
|
||||
}
|
||||
|
||||
async function processDelegationUserCredential(
|
||||
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
|
||||
) {
|
||||
try {
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
const googleCalendarService = await getCalendarService(delegationUserCredential);
|
||||
if (!googleCalendarService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const externalCalendarId = await fetchPrimaryCalendarId({
|
||||
googleCalendarService,
|
||||
delegationUserCredential,
|
||||
});
|
||||
|
||||
if (!externalCalendarId) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (externalCalendarId !== userEmail) {
|
||||
// We don't know the scenario when Delegation Credential allows access to an email that is not the primary calendar email
|
||||
// So log a warning for further investigation
|
||||
log.warn(
|
||||
`External calendar id mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify({
|
||||
externalCalendarId,
|
||||
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 +236,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as Sentry from "@sentry/nextjs";
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
@@ -20,6 +21,18 @@ Sentry.init({
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
beforeSend(event) {
|
||||
if (
|
||||
event.exception?.values?.some(
|
||||
(e) =>
|
||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||
e.value?.includes("Object Not Found Matching Id")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
|
||||
@@ -26,6 +26,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
interfaceLanguage: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
recurringEvent: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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;
|
||||
|
||||
@@ -141,6 +141,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
title: eventData.title,
|
||||
users: eventHostsUserData,
|
||||
hidden: eventData.hidden,
|
||||
interfaceLanguage: eventData.interfaceLanguage,
|
||||
},
|
||||
booking,
|
||||
user: teamSlug,
|
||||
@@ -206,6 +207,7 @@ const getTeamWithEventsData = async (
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function ForgotPassword(props: PageProps) {
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
"--cal-brand-emphasis": "#101010",
|
||||
"--cal-brand-text": "white",
|
||||
"--cal-brand-text": "Black",
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}>
|
||||
@@ -127,7 +127,7 @@ export default function ForgotPassword(props: PageProps) {
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full justify-center dark:bg-white dark:text-black"
|
||||
className="w-full justify-center bg-white hover:bg-black hover:text-white"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
|
||||
@@ -115,6 +115,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 +129,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
@@ -214,6 +216,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 +353,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
||||
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { parseEventTypeColor } from "@calcom/lib";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
@@ -24,6 +23,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
@@ -61,7 +63,19 @@ export default function BillingCredits() {
|
||||
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("credits")}</h2>
|
||||
<p>{t("view_and_manage_credits_description")}</p>
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey="view_and_manage_credits_description"
|
||||
components={[
|
||||
<Link
|
||||
key="Credit System"
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
||||
Learn more
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
<div className="-mx-6 mt-6">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
@@ -90,7 +104,6 @@ export default function BillingCredits() {
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Label>
|
||||
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
|
||||
</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.2.16",
|
||||
"version": "5.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { permissionsRouter } from "@calcom/trpc/server/routers/viewer/pbac/_router";
|
||||
|
||||
export default createNextApiHandler(permissionsRouter);
|
||||
@@ -76,7 +76,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const creditService = new CreditService();
|
||||
|
||||
if (countryCode === "US" || countryCode === "CA") {
|
||||
// SMS to US and CA are free on a team plan
|
||||
// SMS to US and CA are free
|
||||
let teamIdToCharge = parsedTeamId;
|
||||
|
||||
if (!teamIdToCharge && parsedUserId) {
|
||||
@@ -100,7 +100,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
credits: 0,
|
||||
});
|
||||
|
||||
return res.status(200).send(`SMS to US and CA are free on a team plan. Credits set to 0`);
|
||||
return res.status(200).send(`SMS to US and CA are free. Credits set to 0`);
|
||||
}
|
||||
|
||||
let orgId;
|
||||
|
||||
@@ -421,6 +421,145 @@ test.describe("Event Types tests", () => {
|
||||
await expect(offerSeatsToggle).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Interface Language Tests", () => {
|
||||
test.use({
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
test("by default the Interface language has 'Visitor's browser language' selected", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await test.step("should create a en user", async () => {
|
||||
const user = await users.create({
|
||||
locale: "en",
|
||||
});
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
});
|
||||
await test.step("should open first eventType and check Interface Language", async () => {
|
||||
await gotoFirstEventType(page);
|
||||
const interfaceLanguageValue = page
|
||||
.getByTestId("event-interface-language")
|
||||
.locator('div[class$="-singleValue"]');
|
||||
await expect(interfaceLanguageValue).toHaveText("Visitor's browser language");
|
||||
});
|
||||
});
|
||||
|
||||
test("user can change the interface language to any other language and the booking page should be rendered in that language", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await test.step("should create a en user", async () => {
|
||||
const user = await users.create({
|
||||
locale: "en",
|
||||
});
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
});
|
||||
|
||||
await test.step("should open first eventType and change Interface Language to Deutsche", async () => {
|
||||
await gotoFirstEventType(page);
|
||||
await page.getByTestId("event-interface-language").click();
|
||||
await page.locator(`text="Deutsch"`).click();
|
||||
await saveEventType(page);
|
||||
});
|
||||
|
||||
await test.step("should open corresponding booking page and ensure language rendered is Deutsche", async () => {
|
||||
await gotoBookingPage(page);
|
||||
//expect the slot selection page to be rendered in 'Deutsch'
|
||||
await expect(page.locator(`text="So"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Mo"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Di"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Mi"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Do"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Fr"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Sa"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="12 Std"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="24 Std"`).nth(0)).toBeVisible();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
//expect the booking inputs page to be rendered in 'Deutsch'
|
||||
await expect(page.locator(`text="Ihr Name"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="E-Mail Adresse"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Zusätzliche Notizen"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="+ Weitere Gäste"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Zurück"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Bestätigen"`).nth(0)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("should be able to book successfully and ensure success page is rendered in Deutsche", async () => {
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator(`text="Dieser Termin ist geplant"`).nth(0)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("user locale setting is overridden by event type language setting for booking page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await test.step("should create a de user and ensure app is rendered in de", async () => {
|
||||
const user = await users.create({
|
||||
locale: "de",
|
||||
});
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
{
|
||||
const locator = page.getByText("Ereignistypen", { exact: true }).first(); // "general"
|
||||
await expect(locator).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("should open first eventType and change Interface Language to Español", async () => {
|
||||
await page.goto("/event-types");
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await gotoFirstEventType(page);
|
||||
await page.getByTestId("event-interface-language").click();
|
||||
await page.getByTestId("select-option-es").click();
|
||||
await saveEventType(page);
|
||||
});
|
||||
|
||||
await test.step("should go to booking page and verify the Interface language is Español", async () => {
|
||||
await gotoBookingPage(page);
|
||||
//expect the slot selection page to be rendered in 'Español'
|
||||
await expect(page.locator(`text="dom"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="lun"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="mar"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="mié"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="jue"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="vie"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="sáb"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="12 h"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="24hs"`).nth(0)).toBeVisible();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
//expect the booking inputs page to be rendered in 'Español'
|
||||
await expect(page.locator(`text="Tu Nombre"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Email"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Notas Adicionales"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Añadir invitados"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Atrás"`).nth(0)).toBeVisible();
|
||||
await expect(page.locator(`text="Confirmar"`).nth(0)).toBeVisible();
|
||||
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("ensure other components of the App is still rendered in de and not affected by setting eventType Interface Language to Español", async () => {
|
||||
await page.goto("/event-types");
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
{
|
||||
const locator = page.getByText("Ereignistypen", { exact: true }).first(); // "general"
|
||||
await expect(locator).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const selectAttendeePhoneNumber = async (page: Page) => {
|
||||
|
||||
@@ -9,18 +9,65 @@ export async function selectFilter(page: Page, columnId: string) {
|
||||
await page.getByTestId(`add-filter-item-${columnId}`).click();
|
||||
}
|
||||
|
||||
export async function openFilter(page: Page, columnId: string) {
|
||||
await page.getByTestId(`filter-popover-trigger-${columnId}`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a filter with a specific value
|
||||
*/
|
||||
export async function applyFilter(page: Page, columnId: string, value: string) {
|
||||
export async function applySelectFilter(page: Page, columnId: string, value: string) {
|
||||
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||
if (!(await existingFilter.isVisible())) {
|
||||
await selectFilter(page, columnId);
|
||||
}
|
||||
await openFilter(page, columnId);
|
||||
await selectOptionValue(page, columnId, value);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await page.getByTestId(`filter-popover-trigger-${columnId}`).click();
|
||||
|
||||
export async function selectOptionValue(page: Page, columnId: string, value: string) {
|
||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
|
||||
}
|
||||
|
||||
export async function applyTextFilter(page: Page, columnId: string, operator: string, operand?: string) {
|
||||
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||
if (!(await existingFilter.isVisible())) {
|
||||
await selectFilter(page, columnId);
|
||||
}
|
||||
await openFilter(page, columnId);
|
||||
|
||||
await page.getByTestId(`text-filter-options-select-${columnId}`).click();
|
||||
await page
|
||||
.locator(`[data-testid="text-filter-options-${columnId}"] [id^="react-select-"]`)
|
||||
.getByText(operator)
|
||||
.first()
|
||||
.click();
|
||||
if (operand) {
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type(operand);
|
||||
}
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function applyNumberFilter(page: Page, columnId: string, operator: string, operand: number) {
|
||||
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||
if (!(await existingFilter.isVisible())) {
|
||||
await selectFilter(page, columnId);
|
||||
}
|
||||
await openFilter(page, columnId);
|
||||
|
||||
await page.getByTestId(`number-filter-options-select-${columnId}`).click();
|
||||
await page
|
||||
.locator(`[data-testid="number-filter-options-${columnId}"] [id^="react-select-"]`)
|
||||
.getByText(operator)
|
||||
.first()
|
||||
.click();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type(String(operand));
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
applyFilter,
|
||||
applySelectFilter,
|
||||
createFilterSegment,
|
||||
selectSegment,
|
||||
deleteSegment,
|
||||
@@ -56,7 +56,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
const segmentName = "Admin Users";
|
||||
|
||||
await test.step("Can apply and save a role filter as a segment", async () => {
|
||||
await applyFilter(page, "role", "admin");
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
|
||||
await expect(page.getByText(adminUser.email)).toBeVisible();
|
||||
await expect(page.getByText(memberUser.email)).toBeHidden();
|
||||
@@ -110,7 +110,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
const dataTable = page.getByTestId("user-list-data-table");
|
||||
await expect(dataTable).toBeVisible();
|
||||
|
||||
await applyFilter(page, "role", "admin");
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
const segmentName = "Admin Users Persistent";
|
||||
await createFilterSegment(page, segmentName);
|
||||
|
||||
@@ -158,7 +158,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
const segmentName = "Team Admin Filter";
|
||||
|
||||
await test.step("Can create a team scope filter segment", async () => {
|
||||
await applyFilter(page, "role", "admin");
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
|
||||
await createFilterSegment(page, segmentName, {
|
||||
teamScope: true,
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
selectFilter,
|
||||
openFilter,
|
||||
clearFilters,
|
||||
applySelectFilter,
|
||||
applyTextFilter,
|
||||
applyNumberFilter,
|
||||
selectOptionValue,
|
||||
} from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Insights > Routing Filters", () => {
|
||||
test("formId filter: should filter by selected routing form", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName1 = "Test Form 1";
|
||||
const form1 = await routingForms.create({
|
||||
name: formName1,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formName2 = "Test Form 2";
|
||||
const form2 = await routingForms.create({
|
||||
name: formName2,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Email",
|
||||
identifier: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await openFilter(page, "formId");
|
||||
await selectOptionValue(page, "formId", formName1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText(formName1)).toBeVisible();
|
||||
await expect(page.getByText(formName2)).toBeHidden();
|
||||
|
||||
await openFilter(page, "formId");
|
||||
await selectOptionValue(page, "formId", formName2);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText(formName2)).toBeVisible();
|
||||
await expect(page.getByText(formName1)).toBeHidden();
|
||||
});
|
||||
|
||||
test("bookingUserId filter: should filter by user", async ({ page, users, routingForms, prisma }) => {
|
||||
const owner = await users.create(
|
||||
{ name: "owner" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
}
|
||||
);
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const user1Name = "User One";
|
||||
const user1 = await users.create({ name: user1Name });
|
||||
const user2 = await users.create({ name: "User Two" });
|
||||
await prisma.membership.createMany({
|
||||
data: [
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
userId: user1.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
userId: user2.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const formName = "Filter User Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const booking1 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 1",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user1.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user2.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "John Doe",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: booking1.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "Jane Smith",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: booking2.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await applySelectFilter(page, "bookingUserId", user1Name);
|
||||
|
||||
await expect(page.getByText("John Doe")).toBeVisible();
|
||||
await expect(page.getByText("Jane Smith")).toBeHidden();
|
||||
|
||||
await prisma.booking.delete({ where: { id: booking1.id } });
|
||||
await prisma.booking.delete({ where: { id: booking2.id } });
|
||||
});
|
||||
|
||||
// We will add it back when we enable the bookingAttendees filter on /insights/routing
|
||||
test.skip("bookingAttendees filter: should filter by attendee name or email", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName = "Attendee Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
const booking1 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 1",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: owner.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
attendees: {
|
||||
create: [
|
||||
{
|
||||
email: "john.doe@example.com",
|
||||
name: "John Doe",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: owner.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
attendees: {
|
||||
create: [
|
||||
{
|
||||
email: "jane.smith@example.com",
|
||||
name: "Jane Smith",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: { [fieldId]: { label: "Name", value: "Response 1" } },
|
||||
routedToBookingUid: booking1.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: { [fieldId]: { label: "Name", value: "Response 2" } },
|
||||
routedToBookingUid: booking2.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await selectFilter(page, "bookingAttendees");
|
||||
await openFilter(page, "bookingAttendees");
|
||||
await page.getByPlaceholder("Filter attendees by name or email").fill("John");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByText("Response 1")).toBeVisible();
|
||||
await expect(page.getByText("Response 2")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await selectFilter(page, "bookingAttendees");
|
||||
await openFilter(page, "bookingAttendees");
|
||||
await page.getByPlaceholder("Filter attendees by name or email").fill("jane.smith@example");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByText("Response 2")).toBeVisible();
|
||||
await expect(page.getByText("Response 1")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await selectFilter(page, "bookingAttendees");
|
||||
await openFilter(page, "bookingAttendees");
|
||||
await page.getByPlaceholder("Filter attendees by name or email").fill("JANE");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByText("Response 2")).toBeVisible();
|
||||
await expect(page.getByText("Response 1")).toBeHidden();
|
||||
|
||||
await prisma.booking.delete({ where: { id: booking1.id } });
|
||||
await prisma.booking.delete({ where: { id: booking2.id } });
|
||||
});
|
||||
|
||||
test("TEXT field: should filter with different text operators", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const textFieldId = uuidv4();
|
||||
const formName = "Text Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Description",
|
||||
identifier: "description",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Description",
|
||||
value: "This is a test description",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Description",
|
||||
value: "Another description for testing",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-3",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Description",
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await applyTextFilter(page, fieldId, "Contains", "test");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeVisible();
|
||||
await expect(page.getByText("Another description for testing")).toBeVisible();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyTextFilter(page, fieldId, "Is", "This is a test description");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeVisible();
|
||||
await expect(page.getByText("Another description for testing")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyTextFilter(page, fieldId, "Is empty");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeHidden();
|
||||
await expect(page.getByText("Another description for testing")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyTextFilter(page, fieldId, "Contains", "TEST");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeVisible();
|
||||
await expect(page.getByText("Another description for testing")).toBeVisible();
|
||||
});
|
||||
|
||||
test("NUMBER field: should filter with different number operators", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName = "Number Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "number",
|
||||
label: "Rating",
|
||||
identifier: "rating",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Rating",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Rating",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-3",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Rating",
|
||||
value: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await applyNumberFilter(page, fieldId, "=", 3);
|
||||
|
||||
const getByNumber = (number: number) =>
|
||||
page.locator(`[data-testid="data-table-td-${fieldId}"]`).getByText(String(number));
|
||||
|
||||
await expect(getByNumber(3)).toBeVisible();
|
||||
await expect(getByNumber(1)).toBeHidden();
|
||||
await expect(getByNumber(5)).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyNumberFilter(page, fieldId, ">", 3);
|
||||
|
||||
await expect(getByNumber(5)).toBeVisible();
|
||||
await expect(getByNumber(1)).toBeHidden();
|
||||
await expect(getByNumber(3)).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyNumberFilter(page, fieldId, "≤", 3);
|
||||
|
||||
await expect(getByNumber(1)).toBeVisible();
|
||||
await expect(getByNumber(3)).toBeVisible();
|
||||
await expect(getByNumber(5)).toBeHidden();
|
||||
});
|
||||
|
||||
test("SINGLE_SELECT and MULTI_SELECT fields: should filter correctly", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const singleSelectFieldId = uuidv4();
|
||||
const multiSelectFieldId = uuidv4();
|
||||
const formName = "Select Filter Test Form";
|
||||
|
||||
const locationOptionId1 = uuidv4();
|
||||
const locationOptionId2 = uuidv4();
|
||||
|
||||
const skillOptionId1 = uuidv4();
|
||||
const skillOptionId2 = uuidv4();
|
||||
const skillOptionId3 = uuidv4();
|
||||
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "Location",
|
||||
identifier: "location",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: locationOptionId1,
|
||||
label: "New York",
|
||||
},
|
||||
{
|
||||
id: locationOptionId2,
|
||||
label: "London",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "multiselect",
|
||||
label: "Skills",
|
||||
identifier: "skills",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: skillOptionId1,
|
||||
label: "JavaScript",
|
||||
},
|
||||
{
|
||||
id: skillOptionId2,
|
||||
label: "TypeScript",
|
||||
},
|
||||
{
|
||||
id: skillOptionId3,
|
||||
label: "React",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const locationFieldId = (form.fields as any)[0].id;
|
||||
const skillFieldId = (form.fields as any)[1].id;
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[locationFieldId]: {
|
||||
label: "Location",
|
||||
value: locationOptionId1,
|
||||
},
|
||||
[skillFieldId]: {
|
||||
label: "Skills",
|
||||
value: [skillOptionId1, skillOptionId2],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[locationFieldId]: {
|
||||
label: "Location",
|
||||
value: locationOptionId2,
|
||||
},
|
||||
[skillFieldId]: {
|
||||
label: "Skills",
|
||||
value: [skillOptionId2, skillOptionId3],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
const getByColumnValue = (columnId: string, value: string) =>
|
||||
page.locator(`[data-testid="data-table-td-${columnId}"]`).getByText(value);
|
||||
|
||||
await applySelectFilter(page, locationFieldId, "New York");
|
||||
await expect(getByColumnValue(locationFieldId, "New York")).toBeVisible();
|
||||
await expect(getByColumnValue(locationFieldId, "London")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applySelectFilter(page, skillFieldId, "JavaScript");
|
||||
await applySelectFilter(page, skillFieldId, "TypeScript");
|
||||
await expect(getByColumnValue(skillFieldId, "JavaScript")).toBeVisible();
|
||||
await expect(getByColumnValue(skillFieldId, "TypeScript")).toBeVisible();
|
||||
await expect(getByColumnValue(skillFieldId, "React")).toBeHidden();
|
||||
});
|
||||
|
||||
test("createdAt filter: should filter by date range", async ({ page, users, routingForms, prisma }) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName = "Date Range Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 10);
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-old",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "Old Response",
|
||||
},
|
||||
},
|
||||
createdAt: oldDate,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-recent",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "Recent Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await openFilter(page, "createdAt");
|
||||
await page.getByTestId("date-range-options-w").click(); // Last 7 Days
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText("Recent Response")).toBeVisible();
|
||||
await expect(page.getByText("Old Response")).toBeHidden();
|
||||
|
||||
await openFilter(page, "createdAt");
|
||||
await page.getByTestId("date-range-options-t").click(); // Last 30 Days
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText("Recent Response")).toBeVisible();
|
||||
await expect(page.getByText("Old Response")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1779,6 +1779,7 @@
|
||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||
"attendee_email_variable": "Attendee email",
|
||||
"attendee_email_info": "The person booking's email",
|
||||
"booking_uid": "Booking UID",
|
||||
"kbar_search_placeholder": "Type a command or search...",
|
||||
"invalid_credential": "It looks like permissions expired or were revoked for {{appName}}.",
|
||||
"invalid_credential_action": "Reinstall app",
|
||||
@@ -3024,7 +3025,7 @@
|
||||
"buy_credits": "Buy Credits",
|
||||
"credits": "Credits",
|
||||
"view_and_manage_credits": "View and manage credits",
|
||||
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD)",
|
||||
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD). <0>Learn more</0>",
|
||||
"buy_additional_credits": "Buy additional credits ($0.01 per credit)",
|
||||
"overview": "Overview",
|
||||
"organization_slug_taken": "Organization slug is already taken",
|
||||
@@ -3118,6 +3119,8 @@
|
||||
"could_not_find_slug_to_publish_org": "Could not find slug to publish the organization",
|
||||
"picklist": "Picklist",
|
||||
"most_cancelled_bookings": "Most Cancelled Bookings",
|
||||
"interface_language": "Interface Language",
|
||||
"visitors_browser_language": "Visitor's browser language",
|
||||
"name_or_email": "Name or Email",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists",
|
||||
"credit_purchase_failed": "Credit purchase failed. Please try again or contact support.",
|
||||
|
||||
@@ -1089,7 +1089,7 @@
|
||||
"learn_more": "Tudj meg többet",
|
||||
"privacy_policy": "Adatvédelmi Irányelvek",
|
||||
"terms_of_service": "Szolgáltatás feltételei",
|
||||
"remove": "Ektávolítás",
|
||||
"remove": "Eltávolítás",
|
||||
"add": "Hozzáadás",
|
||||
"installed_other": "{{count}} telepítve",
|
||||
"verify_wallet": "A Tárca ellenőrzése",
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as Sentry from "@sentry/nextjs";
|
||||
Sentry.init({
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
integrations: [Sentry.prismaIntegration()],
|
||||
beforeSend(event) {
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
}
|
||||
],
|
||||
"functions": {
|
||||
"pages/api/trpc/workflows/[trpc].ts": {
|
||||
"maxDuration": 800
|
||||
},
|
||||
"pages/api/trpc/highPerf/[trpc].ts": {
|
||||
"memory": 3008
|
||||
},
|
||||
|
||||
@@ -10016,7 +10016,7 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||
"summary": "Find out when is an event type ready to be booked.",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10093,7 +10093,7 @@
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"name": "slotFormat",
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
|
||||
@@ -13687,6 +13687,20 @@
|
||||
},
|
||||
"required": ["slug"]
|
||||
},
|
||||
"LocationDefaultFieldInput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"example": "location",
|
||||
"description": "only allowed value for type is `location`. This booking field is displayed only when event type has 2 or more locations in order to allow person doing the booking pick the location."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["slug"]
|
||||
},
|
||||
"NotesDefaultFieldInput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13871,6 +13885,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -14429,6 +14446,9 @@
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["isDefault", "slug", "type", "required", "hidden"]
|
||||
@@ -14438,7 +14458,7 @@
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": ["name", "email", "title", "notes", "guests"],
|
||||
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
|
||||
"example": "rescheduleReason",
|
||||
"description": "only allowed value for type is `rescheduleReason`",
|
||||
"default": "rescheduleReason"
|
||||
@@ -14478,7 +14498,7 @@
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": ["name", "email", "title", "notes", "guests"],
|
||||
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
|
||||
"example": "title",
|
||||
"description": "only allowed value for type is `title`",
|
||||
"default": "title"
|
||||
@@ -14518,7 +14538,7 @@
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": ["name", "email", "title", "notes", "guests"],
|
||||
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
|
||||
"example": "notes",
|
||||
"description": "only allowed value for type is `notes`",
|
||||
"default": "notes"
|
||||
@@ -14558,7 +14578,7 @@
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": ["name", "email", "title", "notes", "guests"],
|
||||
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
|
||||
"example": "guests",
|
||||
"description": "only allowed value for type is `guests`",
|
||||
"default": "guests"
|
||||
@@ -15656,6 +15676,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -17205,6 +17228,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -17996,6 +18022,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
|
||||
+5
-2
@@ -79,10 +79,13 @@
|
||||
"web": "yarn workspace @calcom/web",
|
||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop"
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||
"changesets-version": "yarn changeset version",
|
||||
"changesets-release": "turbo run build && yarn changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.26.1",
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
"@changesets/cli": "2.29.4",
|
||||
"@jetstreamapp/soql-parser-js": "^6.1.0",
|
||||
"@playwright/test": "^1.45.3",
|
||||
"@snaplet/copycat": "^4.1.0",
|
||||
|
||||
Binary file not shown.
@@ -31,17 +31,14 @@ async function postHandler(req: NextApiRequest) {
|
||||
const selectedCalendar = await SelectedCalendarRepository.findFirstByGoogleChannelId(channelId);
|
||||
|
||||
if (!selectedCalendar) {
|
||||
throw new HttpError({
|
||||
statusCode: 200,
|
||||
message: `No selected calendar found for googleChannelId: ${channelId}`,
|
||||
});
|
||||
log.info("postHandler", `No selected calendar found for googleChannelId: ${channelId}`);
|
||||
return { message: "ok" };
|
||||
}
|
||||
const { credential } = selectedCalendar;
|
||||
if (!credential)
|
||||
throw new HttpError({
|
||||
statusCode: 200,
|
||||
message: `No credential found for selected calendar for googleChannelId: ${channelId}`,
|
||||
});
|
||||
if (!credential) {
|
||||
log.info("postHandler", `No credential found for selected calendar for googleChannelId: ${channelId}`);
|
||||
return { message: "ok" };
|
||||
}
|
||||
const { selectedCalendars } = credential;
|
||||
const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id });
|
||||
const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache);
|
||||
|
||||
@@ -974,15 +974,15 @@ export default class GoogleCalendarService implements Calendar {
|
||||
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
|
||||
}
|
||||
: {};
|
||||
let error: string | undefined;
|
||||
let error: string | null = null;
|
||||
|
||||
if (!otherCalendarsWithSameSubscription.length) {
|
||||
try {
|
||||
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to watch calendar ${calendarId}`, error);
|
||||
} catch (_error) {
|
||||
this.log.error(`Failed to watch calendar ${calendarId}`, _error);
|
||||
// We set error to prevent attempting to watch on next cron run
|
||||
error = error instanceof Error ? error.message : "Unknown error";
|
||||
error = _error instanceof Error ? _error.message : "Unknown error";
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
|
||||
@@ -167,7 +167,14 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
icon="user-plus"
|
||||
title={t("salesforce_create_event_on_contact")}
|
||||
labelFor="create-event-on-contact">
|
||||
<Switch size="sm" labelOnLeading checked={createEventOnLeadCheckForContact} />
|
||||
<Switch
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={createEventOnLeadCheckForContact}
|
||||
onCheckedChange={(checked) => {
|
||||
setAppData("createEventOnLeadCheckForContact", checked);
|
||||
}}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
<CreateContactUnderAccount />
|
||||
|
||||
@@ -125,7 +125,10 @@ export async function getLocationGroupedOptions(
|
||||
: {}),
|
||||
};
|
||||
if (apps[groupByCategory]) {
|
||||
apps[groupByCategory] = [...apps[groupByCategory], option];
|
||||
const existingOption = apps[groupByCategory].find((o) => o.value === option.value);
|
||||
if (!existingOption) {
|
||||
apps[groupByCategory] = [...apps[groupByCategory], option];
|
||||
}
|
||||
} else {
|
||||
apps[groupByCategory] = [option];
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ vi.mock("@calcom/atoms/hooks/useIsPlatform", () => ({
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
vi.mock("@calcom/lib", () => ({
|
||||
classNames: (...args: string[]) => {
|
||||
vi.mock("@calcom/ui/classNames", () => ({
|
||||
default: (...args: string[]) => {
|
||||
return args.filter(Boolean).join(" ");
|
||||
},
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user