Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a06797a9 | ||
|
|
99eb743772 | ||
|
|
73adfb0cec | ||
|
|
7127c60116 | ||
|
|
a6d37fd167 | ||
|
|
16ea15076d | ||
|
|
041871f1b6 | ||
|
|
8c8ae127fe | ||
|
|
357c82129c | ||
|
|
324ac691eb | ||
|
|
da99001a7a | ||
|
|
bd10d07083 | ||
|
|
0e9b419a07 | ||
|
|
d5bd08d279 | ||
|
|
4cc83530f9 | ||
|
|
4fcf4081d0 | ||
|
|
d84d80eea6 | ||
|
|
f3e1d67de1 | ||
|
|
2ce25f54d4 | ||
|
|
25eac5d29b | ||
|
|
1710e30719 | ||
|
|
6d970d0b57 | ||
|
|
41a6035a12 | ||
|
|
f84c8239e7 | ||
|
|
1dca160db0 | ||
|
|
4ff366cd99 | ||
|
|
21a4711f07 | ||
|
|
f1dd217de7 | ||
|
|
3e8f0b125d | ||
|
|
25246ef8ff | ||
|
|
bb98143e2e | ||
|
|
6258ca14d0 | ||
|
|
d0e17e8611 | ||
|
|
79dc76239b | ||
|
|
95d62354d9 | ||
|
|
b9fadf82a7 | ||
|
|
1740e34c79 | ||
|
|
9e09066e99 | ||
|
|
0a9cfd3a08 | ||
|
|
0be711ad6e | ||
|
|
1c507b8169 | ||
|
|
81bdb3dc91 | ||
|
|
5efc7f3856 | ||
|
|
0afecec607 | ||
|
|
920574de65 |
No files matched your search
@@ -188,6 +188,10 @@ TWILIO_TOKEN=
|
|||||||
TWILIO_MESSAGING_SID=
|
TWILIO_MESSAGING_SID=
|
||||||
TWILIO_PHONE_NUMBER=
|
TWILIO_PHONE_NUMBER=
|
||||||
TWILIO_WHATSAPP_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)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
+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.
|
||||||
@@ -226,20 +226,26 @@ export class EventTypesAtomService {
|
|||||||
} else {
|
} else {
|
||||||
credentials = credentials.concat(teamAppCredentials);
|
credentials = credentials.concat(teamAppCredentials);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
if (slug !== "stripe") {
|
||||||
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
|
||||||
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
|
const { credentials: allCredentials } =
|
||||||
{
|
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
|
||||||
user: {
|
user: {
|
||||||
...user,
|
...user,
|
||||||
credentials,
|
credentials,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
credentials = allCredentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledApps = await getEnabledAppsFromCredentials(
|
||||||
|
credentials as unknown as CredentialDataWithTeamName[],
|
||||||
|
{
|
||||||
|
where: { slug },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
credentials = allCredentials;
|
|
||||||
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
|
|
||||||
where: { slug },
|
|
||||||
});
|
|
||||||
const apps = await Promise.all(
|
const apps = await Promise.all(
|
||||||
enabledApps
|
enabledApps
|
||||||
.filter(({ ...app }) => app.slug === slug)
|
.filter(({ ...app }) => app.slug === slug)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||||
import type { PageProps } from "app/_types";
|
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
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 Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||||
const searchParams = await _searchParams;
|
const searchParams = await _searchParams;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
const _headers = await headers();
|
||||||
|
const _cookies = await cookies();
|
||||||
|
|
||||||
const [meCaller, availabilityCaller] = await Promise.all([
|
const [me, cachedAvailabilities] = await Promise.all([
|
||||||
createRouterCaller(meRouter),
|
getCachedMe(_headers, _cookies),
|
||||||
createRouterCaller(availabilityRouter),
|
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 organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
|
||||||
const isOrgPrivate = organizationId
|
const isOrgPrivate = organizationId
|
||||||
? await OrganizationRepository.checkIfPrivate({
|
? await OrganizationRepository.checkIfPrivate({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||||
import { createRouterCaller } from "app/_trpc/context";
|
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||||
import type { PageProps } from "app/_types";
|
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -23,9 +24,40 @@ export const generateMetadata = async () =>
|
|||||||
"/event-types"
|
"/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 Page = async ({ params, searchParams }: PageProps) => {
|
||||||
const _searchParams = await searchParams;
|
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 });
|
const session = await getServerSession({ req: context.req });
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -34,15 +66,9 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
|||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||||
|
|
||||||
const [meCaller, eventTypesCaller] = await Promise.all([
|
|
||||||
createRouterCaller(meRouter),
|
|
||||||
createRouterCaller(eventTypesRouter),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [me, userEventGroupsData] = await Promise.all([
|
const [me, userEventGroupsData] = await Promise.all([
|
||||||
meCaller.get(),
|
getCachedMe(_headers, _cookies),
|
||||||
eventTypesCaller.getUserEventGroups({ filters }),
|
getCachedEventGroups(_headers, _cookies, filters),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,4 +82,3 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
export const revalidate = 3600; // 1 hour in seconds
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||||
import type { PageProps } from "app/_types";
|
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
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 ServerPage = async ({ params }: PageProps) => {
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -41,8 +51,10 @@ const ServerPage = async ({ params }: PageProps) => {
|
|||||||
throw new Error("Invalid Event Type id");
|
throw new Error("Invalid Event Type id");
|
||||||
}
|
}
|
||||||
const eventTypeId = parsed.data.type;
|
const eventTypeId = parsed.data.type;
|
||||||
const caller = await createRouterCaller(eventTypesRouter);
|
const _headers = await headers();
|
||||||
const data = await caller.get({ id: eventTypeId });
|
const _cookies = await cookies();
|
||||||
|
|
||||||
|
const data = await getCachedEventType(eventTypeId, _headers, _cookies);
|
||||||
if (!data?.eventType) {
|
if (!data?.eventType) {
|
||||||
throw new Error("This event type does not exist");
|
throw new Error("This event type does not exist");
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-10
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
|
|||||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import type { OrganizationBranding } 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 Shell from "@calcom/features/shell/Shell";
|
||||||
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
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 { VerticalTabItem } from "@calcom/ui/components/navigation";
|
||||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||||
|
|
||||||
|
type TeamFeatures = Record<number, Record<keyof AppFlags, boolean>> | null;
|
||||||
|
|
||||||
const getTabs = (orgBranding: OrganizationBranding | null) => {
|
const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||||
const tabs: VerticalTabItemProps[] = [
|
const tabs: VerticalTabItemProps[] = [
|
||||||
{
|
{
|
||||||
@@ -272,6 +276,7 @@ interface SettingsSidebarContainerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
navigationIsOpenedOnMobile?: boolean;
|
navigationIsOpenedOnMobile?: boolean;
|
||||||
bannersHeight?: number;
|
bannersHeight?: number;
|
||||||
|
teamFeatures?: TeamFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamListCollapsible = () => {
|
const TeamListCollapsible = () => {
|
||||||
@@ -438,6 +443,7 @@ const SettingsSidebarContainer = ({
|
|||||||
className = "",
|
className = "",
|
||||||
navigationIsOpenedOnMobile,
|
navigationIsOpenedOnMobile,
|
||||||
bannersHeight,
|
bannersHeight,
|
||||||
|
teamFeatures,
|
||||||
}: SettingsSidebarContainerProps) => {
|
}: SettingsSidebarContainerProps) => {
|
||||||
const searchParams = useCompatSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const orgBranding = useOrgBranding();
|
const orgBranding = useOrgBranding();
|
||||||
@@ -449,11 +455,15 @@ const SettingsSidebarContainer = ({
|
|||||||
}[]
|
}[]
|
||||||
>();
|
>();
|
||||||
const session = useSession();
|
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({
|
const tabsWithPermissions = useTabs({
|
||||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
isDelegationCredentialEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||||
@@ -727,9 +737,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
|||||||
export type SettingsLayoutProps = {
|
export type SettingsLayoutProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
teamFeatures?: TeamFeatures;
|
||||||
} & ComponentProps<typeof Shell>;
|
} & ComponentProps<typeof Shell>;
|
||||||
|
|
||||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const state = useState(false);
|
const state = useState(false);
|
||||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||||
@@ -761,6 +772,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
|||||||
<SidebarContainerElement
|
<SidebarContainerElement
|
||||||
sideContainerOpen={sideContainerOpen}
|
sideContainerOpen={sideContainerOpen}
|
||||||
setSideContainerOpen={setSideContainerOpen}
|
setSideContainerOpen={setSideContainerOpen}
|
||||||
|
teamFeatures={teamFeatures}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
drawerState={state}
|
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 = ({
|
const SidebarContainerElement = ({
|
||||||
sideContainerOpen,
|
sideContainerOpen,
|
||||||
bannersHeight,
|
bannersHeight,
|
||||||
setSideContainerOpen,
|
setSideContainerOpen,
|
||||||
|
teamFeatures,
|
||||||
}: SidebarContainerElementProps) => {
|
}: SidebarContainerElementProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
@@ -797,13 +817,8 @@ const SidebarContainerElement = ({
|
|||||||
<SettingsSidebarContainer
|
<SettingsSidebarContainer
|
||||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||||
bannersHeight={bannersHeight}
|
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 React from "react";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -16,9 +18,22 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
|||||||
return redirect("/auth/login");
|
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 (
|
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 { _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 }> }) =>
|
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -11,8 +15,20 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
|||||||
`/settings/organizations/${(await params).id}/members`
|
`/settings/organizations/${(await params).id}/members`
|
||||||
);
|
);
|
||||||
|
|
||||||
const ServerPageWrapper = () => {
|
const ServerPageWrapper = async () => {
|
||||||
return <MembersPage />;
|
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;
|
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 { createRouterCaller } from "app/_trpc/context";
|
||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
|
||||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||||
import LegacyPage from "@calcom/features/ee/organizations/pages/members";
|
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
|
||||||
|
import { MembersView } from "~/members/members-view";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -15,17 +16,20 @@ export const generateMetadata = async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Page = 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 = (
|
return (
|
||||||
<SettingsHeader
|
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||||
title={t("organization_members")}
|
|
||||||
description={t("organization_description")}
|
|
||||||
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
|
|
||||||
<LegacyPage />
|
|
||||||
</SettingsHeader>
|
|
||||||
);
|
);
|
||||||
return await SettingsLayoutAppDir({ children, containerClassName: "lg:max-w-screen-2xl" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
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 { _generateMetadata } from "app/_utils";
|
||||||
|
|
||||||
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
|
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 () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -11,8 +14,26 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/platform/members"
|
"/settings/platform/members"
|
||||||
);
|
);
|
||||||
|
|
||||||
const ServerPageWrapper = () => {
|
const ServerPageWrapper = async () => {
|
||||||
return <PlatformMembersView />;
|
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;
|
export default ServerPageWrapper;
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
|
import type { ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||||
|
import type { TRPCContext } from "@calcom/trpc/types/server/createContext";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
import type { AnyRouter } from "@trpc/server";
|
import type { AnyRouter } from "@trpc/server";
|
||||||
|
|
||||||
const getTRPCContext = async () => {
|
export const getTRPCContext = async (_headers?: ReadonlyHeaders, _cookies?: ReadonlyRequestCookies) => {
|
||||||
const legacyReq = buildLegacyRequest(await headers(), await cookies());
|
const legacyReq = buildLegacyRequest(_headers ?? (await headers()), _cookies ?? (await cookies()));
|
||||||
return await createContext({ req: legacyReq, res: {} as any }, getServerSession);
|
return await createContext({ req: legacyReq, res: {} as any }, getServerSession);
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter) {
|
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter, context?: TRPCContext) {
|
||||||
const trpcContext = await getTRPCContext();
|
const trpcContext = context ? context : await getTRPCContext();
|
||||||
const createCaller = createCallerFactory<TRouter>(router);
|
const createCaller = createCallerFactory<TRouter>(router);
|
||||||
return createCaller(trpcContext);
|
return createCaller(trpcContext);
|
||||||
}
|
}
|
||||||
@@ -12,3 +12,6 @@ export type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
|
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
|
||||||
|
|
||||||
|
export { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||||
|
export { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { handleCreateCredentials } from "../route";
|
||||||
|
|
||||||
|
type OrgParams = { id: number };
|
||||||
|
const createOrg = async ({ id }: OrgParams) =>
|
||||||
|
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
|
||||||
|
|
||||||
|
type UserParams = { id: number; email: string };
|
||||||
|
const createUser = async ({ id, email }: UserParams) => await prismock.user.create({ data: { id, email } });
|
||||||
|
|
||||||
|
type WorkspacePlatformParams = { id: number; name?: string; slug?: string; description?: string };
|
||||||
|
const createWorkspacePlatform = async ({ id, name, slug, description }: WorkspacePlatformParams) =>
|
||||||
|
await prismock.workspacePlatform.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: name ?? "Google",
|
||||||
|
slug: slug ?? "google",
|
||||||
|
description: description ?? "Test platform",
|
||||||
|
defaultServiceAccountKey: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type DelegationCredentialParams = {
|
||||||
|
id: string;
|
||||||
|
orgId: number;
|
||||||
|
domain: string;
|
||||||
|
workspacePlatformId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDelegationCredential = async ({
|
||||||
|
id,
|
||||||
|
orgId,
|
||||||
|
domain,
|
||||||
|
workspacePlatformId,
|
||||||
|
}: DelegationCredentialParams) =>
|
||||||
|
await prismock.delegationCredential.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
enabled: true,
|
||||||
|
domain,
|
||||||
|
organizationId: orgId,
|
||||||
|
workspacePlatformId,
|
||||||
|
serviceAccountKey: {
|
||||||
|
client_email: "svc@example.com",
|
||||||
|
private_key: "pk",
|
||||||
|
client_id: "cid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type CredentialParams = { id?: number; userId?: number; delegationCredentialId?: string; key?: object };
|
||||||
|
const createCredential = async ({
|
||||||
|
id = 1,
|
||||||
|
userId = 1,
|
||||||
|
delegationCredentialId = "delegation-credential-1",
|
||||||
|
key = {},
|
||||||
|
}: CredentialParams = {}) =>
|
||||||
|
await prismock.credential.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
type: "google_calendar",
|
||||||
|
key,
|
||||||
|
userId,
|
||||||
|
appId: "google_calendar",
|
||||||
|
delegationCredentialId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type MembershipParams = { teamId: number; userId: number; role?: MembershipRole };
|
||||||
|
const createMembership = async ({ teamId, userId, role = "MEMBER" }: MembershipParams) =>
|
||||||
|
await prismock.membership.create({ data: { teamId, userId, accepted: true, role } });
|
||||||
|
|
||||||
|
// Helper to assert credentials in DB match expected (partial, order-insensitive)
|
||||||
|
type PartialCredential = Partial<Awaited<ReturnType<typeof prismock.credential.create>>>;
|
||||||
|
async function expectCredentials(expected: PartialCredential[]) {
|
||||||
|
const actual = await prismock.credential.findMany({});
|
||||||
|
expect(actual.length).toBe(expected.length);
|
||||||
|
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Delegation Credentials: Organization-wide Google Calendar Access", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
prismock.delegationCredential.deleteMany();
|
||||||
|
prismock.credential.deleteMany();
|
||||||
|
prismock.user.deleteMany();
|
||||||
|
prismock.team.deleteMany();
|
||||||
|
prismock.workspacePlatform.deleteMany();
|
||||||
|
prismock.membership.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a helpful message when no Delegation Credentials are enabled", async () => {
|
||||||
|
const result = await handleCreateCredentials();
|
||||||
|
expect(result).toEqual({
|
||||||
|
message: "No enabled delegation credentials found",
|
||||||
|
success: 0,
|
||||||
|
failures: 0,
|
||||||
|
});
|
||||||
|
await expectCredentials([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates credentials for all eligible organization members when Delegation Credential is enabled", async () => {
|
||||||
|
const org = await createOrg({ id: 1 });
|
||||||
|
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
|
||||||
|
const user1 = await createUser({ id: 1, email: "alice@example.com" });
|
||||||
|
const user2 = await createUser({ id: 2, email: "bob@example.com" });
|
||||||
|
await createMembership({ teamId: org.id, userId: user1.id });
|
||||||
|
await createMembership({ teamId: org.id, userId: user2.id });
|
||||||
|
await createDelegationCredential({
|
||||||
|
id: "delegation-credential-1",
|
||||||
|
orgId: org.id,
|
||||||
|
domain: "example.com",
|
||||||
|
workspacePlatformId: workspacePlatform.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleCreateCredentials();
|
||||||
|
expect(result.success).toBe(2);
|
||||||
|
expect(result.failures).toBe(0);
|
||||||
|
await expectCredentials([
|
||||||
|
{ userId: user1.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||||
|
{ userId: user2.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips credential creation for unsupported workspace platforms", async () => {
|
||||||
|
const org = await createOrg({ id: 1 });
|
||||||
|
// Create a non-google workspace platform
|
||||||
|
const outlookPlatform = await createWorkspacePlatform({
|
||||||
|
id: 2,
|
||||||
|
name: "Outlook",
|
||||||
|
slug: "outlook",
|
||||||
|
description: "Test",
|
||||||
|
});
|
||||||
|
const user = await createUser({ id: 1, email: "user1@outlook.com" });
|
||||||
|
await createMembership({ teamId: org.id, userId: user.id });
|
||||||
|
await createDelegationCredential({
|
||||||
|
id: "delegation-credential-2",
|
||||||
|
orgId: org.id,
|
||||||
|
domain: "outlook.com",
|
||||||
|
workspacePlatformId: outlookPlatform.id,
|
||||||
|
});
|
||||||
|
const result = await handleCreateCredentials();
|
||||||
|
expect(result.success).toBe(0);
|
||||||
|
await expectCredentials([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create duplicate credentials for users who already have one", async () => {
|
||||||
|
const org = await createOrg({ id: 1 });
|
||||||
|
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
|
||||||
|
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||||
|
await createMembership({ teamId: org.id, userId: user.id });
|
||||||
|
await createDelegationCredential({
|
||||||
|
id: "delegation-credential-1",
|
||||||
|
orgId: org.id,
|
||||||
|
domain: "example.com",
|
||||||
|
workspacePlatformId: workspacePlatform.id,
|
||||||
|
});
|
||||||
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: "delegation-credential-1" });
|
||||||
|
const result = await handleCreateCredentials();
|
||||||
|
expect(result.success).toBe(0);
|
||||||
|
await expectCredentials([
|
||||||
|
{ userId: user.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes only a batch of members at a time if there are more than the batch size", async () => {
|
||||||
|
const org = await createOrg({ id: 1 });
|
||||||
|
const workspacePlatform = await createWorkspacePlatform({
|
||||||
|
id: 1,
|
||||||
|
name: "Google",
|
||||||
|
slug: "google",
|
||||||
|
description: "Test platform",
|
||||||
|
});
|
||||||
|
// Simulate more than 100 delegated members
|
||||||
|
const users = [];
|
||||||
|
for (let i = 1; i <= 120; i++) {
|
||||||
|
const user = await createUser({ id: i, email: `user${i}@example.com` });
|
||||||
|
users.push(user);
|
||||||
|
await createMembership({ teamId: org.id, userId: user.id });
|
||||||
|
}
|
||||||
|
await createDelegationCredential({
|
||||||
|
id: "delegation-credential-1",
|
||||||
|
orgId: org.id,
|
||||||
|
domain: "example.com",
|
||||||
|
workspacePlatformId: workspacePlatform.id,
|
||||||
|
});
|
||||||
|
const result = await handleCreateCredentials();
|
||||||
|
expect(result.success).toBe(100); // Only batch size processed
|
||||||
|
await expectCredentials(
|
||||||
|
users.slice(0, 100).map((u) => ({
|
||||||
|
userId: u.id,
|
||||||
|
delegationCredentialId: "delegation-credential-1",
|
||||||
|
type: "google_calendar",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import { DelegationCredentialRepository } from "@calcom/lib/server/repository/de
|
|||||||
|
|
||||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||||
|
|
||||||
const log = logger.getSubLogger({ prefix: ["CreateCredentials"] });
|
const moduleLogger = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[credentials/cron]"] });
|
||||||
const batchSizeToCreateCredentials = 100;
|
const batchSizeToCreateCredentials = 100;
|
||||||
const validateRequest = (req: NextRequest) => {
|
const validateRequest = (req: NextRequest) => {
|
||||||
const url = new URL(req.url);
|
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();
|
const delegationCredentials = await DelegationCredentialRepository.findAllEnabledIncludeDelegatedMembers();
|
||||||
|
|
||||||
|
log.info(`Found ${delegationCredentials.length} enabled delegation credentials to create credentials for`);
|
||||||
|
|
||||||
if (!delegationCredentials.length) {
|
if (!delegationCredentials.length) {
|
||||||
return {
|
return {
|
||||||
message: "No enabled delegation credentials found",
|
message: "No enabled delegation credentials found",
|
||||||
@@ -107,9 +110,12 @@ async function handleCreateCredentials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteCredentials() {
|
async function handleDeleteCredentials() {
|
||||||
|
const log = moduleLogger.getSubLogger({ prefix: ["DeleteCredentials"] });
|
||||||
const delegationCredentials =
|
const delegationCredentials =
|
||||||
await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess();
|
await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess();
|
||||||
|
|
||||||
|
log.info(`Found ${delegationCredentials.length} delegation credentials to delete credentials for`);
|
||||||
|
|
||||||
if (!delegationCredentials.length) {
|
if (!delegationCredentials.length) {
|
||||||
return {
|
return {
|
||||||
message: "No disabled delegation credentials found",
|
message: "No disabled delegation credentials found",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ async function postHandler(request: NextRequest) {
|
|||||||
const userIdsFromTeams = team.members.map((u) => u.userId);
|
const userIdsFromTeams = team.members.map((u) => u.userId);
|
||||||
|
|
||||||
// Booking Events
|
// Booking Events
|
||||||
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
|
const whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
@@ -122,7 +122,7 @@ async function postHandler(request: NextRequest) {
|
|||||||
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
|
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
|
||||||
|
|
||||||
// Most Booked Event Type
|
// Most Booked Event Type
|
||||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
const bookingWhere: Prisma.BookingTimeStatusDenormalizedWhereInput = {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
|
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
|
||||||
lte: dayjs(new Date()).endOf("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"],
|
by: ["eventTypeId"],
|
||||||
where: bookingWhere,
|
where: bookingWhere,
|
||||||
_count: {
|
_count: {
|
||||||
@@ -234,7 +234,7 @@ async function postHandler(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Most booked members
|
// Most booked members
|
||||||
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
|
const bookingsFromTeam = await prisma.bookingTimeStatusDenormalized.groupBy({
|
||||||
by: ["userId"],
|
by: ["userId"],
|
||||||
where: bookingWhere,
|
where: bookingWhere,
|
||||||
_count: {
|
_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 { NextResponse } from "next/server";
|
||||||
|
|
||||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||||
|
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
|
||||||
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
|
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
@@ -19,7 +20,7 @@ import type { Ensure } from "@calcom/types/utils";
|
|||||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||||
|
|
||||||
const limitOnQueryingGoogleCalendar = 50;
|
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 validateRequest = (req: NextRequest) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
|
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
|
// These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere
|
||||||
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
|
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
|
||||||
type: "google_calendar",
|
type: "google_calendar",
|
||||||
take: limitOnQueryingGoogleCalendar,
|
take: limitOnQueryingGoogleCalendar,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.info(`Found ${allDelegationUserCredentials.length} delegation user credentials to process`);
|
||||||
|
|
||||||
if (!allDelegationUserCredentials.length) {
|
if (!allDelegationUserCredentials.length) {
|
||||||
const message = "No delegation credentials found";
|
const message = "No delegation credentials found";
|
||||||
log.info(message);
|
log.info(message);
|
||||||
@@ -57,106 +236,16 @@ async function handleCreateSelectedCalendars() {
|
|||||||
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
|
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
|
||||||
groupedDelegationUserCredentials
|
groupedDelegationUserCredentials
|
||||||
)) {
|
)) {
|
||||||
// First, create an array of promises that check if each credential should be processed
|
log.info(`Processing delegation user credentials for delegationCredentialId: ${delegationCredentialId}`);
|
||||||
const checkedDelegationUserCredentials = await Promise.all(
|
const delegationUserCredentialsToProcess = await getDelegationUserCredentialsToProcess(
|
||||||
delegationUserCredentials.map(async (delegationUserCredential) => {
|
delegationUserCredentials
|
||||||
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;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const delegationUserCredentialsToProcess = checkedDelegationUserCredentials.filter(
|
|
||||||
(credential): credential is Ensure<(typeof delegationUserCredentials)[number], "user"> =>
|
|
||||||
credential !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
|
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
delegationUserCredentialsToProcess.map(async (delegationUserCredential) => {
|
delegationUserCredentialsToProcess.map(processDelegationUserCredential)
|
||||||
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;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||||
const failureCount = results.filter((r) => r.status === "rejected").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 { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
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 { Button } from "@calcom/ui/components/button";
|
||||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ function buildParsedBooking(booking: BookingItemProps) {
|
|||||||
>)
|
>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
|
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||||
|
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
eventType: bookingEventType,
|
eventType: bookingEventType,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { EmbedProps } from "app/WithEmbedSSR";
|
import type { EmbedProps } from "app/WithEmbedSSR";
|
||||||
import { createRouterCaller } from "app/_trpc/context";
|
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { z } from "zod";
|
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 { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import type { GetBookingType } 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 { 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 { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { RedirectType } from "@calcom/prisma/enums";
|
import { RedirectType } from "@calcom/prisma/enums";
|
||||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
|
||||||
|
|
||||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
@@ -112,17 +111,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||||
|
|
||||||
// We use this to both prefetch the query on the server,
|
const eventData = await EventRepository.getPublicEvent(
|
||||||
// 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,
|
username: name,
|
||||||
eventSlug: slug,
|
eventSlug: slug,
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
org,
|
org,
|
||||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||||
});
|
},
|
||||||
|
session?.user?.id
|
||||||
|
);
|
||||||
|
|
||||||
if (!eventData) {
|
if (!eventData) {
|
||||||
return notFound;
|
return notFound;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import useMeQuery from "./useMeQuery";
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
|
|
||||||
export const useCurrentUserId = () => {
|
export const useCurrentUserId = () => {
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
|
|
||||||
export function useMeQuery() {
|
|
||||||
const meQuery = trpc.viewer.me.get.useQuery(undefined, {
|
|
||||||
retry(failureCount) {
|
|
||||||
return failureCount > 3;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return meQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useMeQuery;
|
|
||||||
+14
-8
@@ -1,13 +1,13 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { orgDomainConfig } 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 slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
|
||||||
|
|
||||||
const paramsSchema = z.object({
|
const paramsSchema = z.object({
|
||||||
type: z.string().transform((s) => slugify(s)),
|
type: z.string().transform((s) => slugify(s)),
|
||||||
@@ -38,18 +38,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||||
|
if (!org) {
|
||||||
const caller = await createRouterCaller(publicViewerRouter);
|
return {
|
||||||
|
notFound: true,
|
||||||
const eventData = await caller.event({
|
} as const;
|
||||||
|
}
|
||||||
|
const session = await getServerSession({ req: context.req });
|
||||||
|
const eventData = await EventRepository.getPublicEvent(
|
||||||
|
{
|
||||||
username: teamSlug,
|
username: teamSlug,
|
||||||
eventSlug: meetingSlug,
|
eventSlug: meetingSlug,
|
||||||
isTeamEvent: true,
|
isTeamEvent: true,
|
||||||
org,
|
org,
|
||||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||||
});
|
},
|
||||||
|
session?.user?.id
|
||||||
|
);
|
||||||
|
|
||||||
if (!eventData || !org) {
|
if (!eventData) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
import { Alert } from "@calcom/ui/components/alert";
|
import { Alert } from "@calcom/ui/components/alert";
|
||||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||||
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
||||||
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
||||||
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
||||||
|
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
|
||||||
|
|
||||||
import BookingListItem from "@components/booking/BookingListItem";
|
import BookingListItem from "@components/booking/BookingListItem";
|
||||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||||
|
|
||||||
@@ -115,6 +114,7 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||||
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
||||||
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
||||||
|
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
|
||||||
|
|
||||||
const { limit, offset } = useDataTable();
|
const { limit, offset } = useDataTable();
|
||||||
|
|
||||||
@@ -128,6 +128,7 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
userIds,
|
userIds,
|
||||||
attendeeName,
|
attendeeName,
|
||||||
attendeeEmail,
|
attendeeEmail,
|
||||||
|
bookingUid,
|
||||||
afterStartDate: dateRange?.startDate
|
afterStartDate: dateRange?.startDate
|
||||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -214,6 +215,21 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||||
|
id: "bookingUid",
|
||||||
|
header: t("booking_uid"),
|
||||||
|
enableColumnFilter: true,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: () => null,
|
||||||
|
meta: {
|
||||||
|
filter: {
|
||||||
|
type: ColumnFilterType.TEXT,
|
||||||
|
textOptions: {
|
||||||
|
allowedOperators: ["equals"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: "customView",
|
id: "customView",
|
||||||
cell: (props) => {
|
cell: (props) => {
|
||||||
@@ -336,6 +352,7 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
attendeeName: false,
|
attendeeName: false,
|
||||||
attendeeEmail: false,
|
attendeeEmail: false,
|
||||||
dateRange: false,
|
dateRange: false,
|
||||||
|
bookingUid: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ export default function Success(props: PageProps) {
|
|||||||
rescheduleLocation = bookingInfo.responses.location.optionValue;
|
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 = {
|
const bookingWithParsedMetadata = {
|
||||||
...bookingInfo,
|
...bookingInfo,
|
||||||
metadata: parsedBookingMetadata,
|
metadata: parsedBookingMetadata,
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ export default function Type({
|
|||||||
duration,
|
duration,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
durationConfig,
|
durationConfig,
|
||||||
|
eventData,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
return (
|
return (
|
||||||
<BookingPageErrorBoundary>
|
<BookingPageErrorBoundary>
|
||||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||||
<Booker
|
<Booker
|
||||||
|
eventData={eventData}
|
||||||
username={user}
|
username={user}
|
||||||
eventSlug={slug}
|
eventSlug={slug}
|
||||||
bookingData={booking}
|
bookingData={booking}
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||||
import MembersView from "@calcom/features/ee/organizations/pages/members";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import Shell from "@calcom/features/shell/Shell";
|
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";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
const MembersPage: React.FC = () => {
|
export const MembersView = (props: UserListTableProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
|
||||||
|
const canLoggedInUserSeeMembers =
|
||||||
|
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<LicenseRequired>
|
||||||
withoutMain={false}
|
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
|
||||||
title={t("organization_members")}
|
{!canLoggedInUserSeeMembers && (
|
||||||
description={t("organization_description")}
|
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
|
||||||
heading={t("organization_members")}
|
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
|
||||||
subtitle={t("organization_description")}
|
</div>
|
||||||
headerClassName="hidden md:block"
|
)}
|
||||||
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
</LicenseRequired>
|
||||||
<MembersView />
|
|
||||||
</Shell>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MembersPage;
|
|
||||||
+12
-1
@@ -12,13 +12,24 @@ import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary
|
|||||||
|
|
||||||
export type Props = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
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 (
|
return (
|
||||||
<BookingPageErrorBoundary>
|
<BookingPageErrorBoundary>
|
||||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||||
<Booker
|
<Booker
|
||||||
username={user}
|
username={user}
|
||||||
eventSlug={slug}
|
eventSlug={slug}
|
||||||
|
eventData={eventData}
|
||||||
bookingData={booking}
|
bookingData={booking}
|
||||||
hideBranding={isBrandingHidden}
|
hideBranding={isBrandingHidden}
|
||||||
isTeamEvent
|
isTeamEvent
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.3.2",
|
"version": "5.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"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();
|
const creditService = new CreditService();
|
||||||
|
|
||||||
if (countryCode === "US" || countryCode === "CA") {
|
if (countryCode === "US" || countryCode === "CA") {
|
||||||
// SMS to US and CA are free
|
// SMS to US and CA are free for teams
|
||||||
let teamIdToCharge = parsedTeamId;
|
let teamIdToCharge = parsedTeamId;
|
||||||
|
|
||||||
if (!teamIdToCharge && parsedUserId) {
|
if (!teamIdToCharge && parsedUserId) {
|
||||||
@@ -92,15 +92,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
teamIdToCharge = teamMembership?.teamId;
|
teamIdToCharge = teamMembership?.teamId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (teamIdToCharge) {
|
||||||
await creditService.chargeCredits({
|
await creditService.chargeCredits({
|
||||||
teamId: teamIdToCharge,
|
teamId: teamIdToCharge,
|
||||||
userId: !teamIdToCharge ? parsedUserId : undefined,
|
|
||||||
bookingUid: parsedBookingUid,
|
bookingUid: parsedBookingUid,
|
||||||
smsSid,
|
smsSid,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(`SMS to US and CA are free. Credits set to 0`);
|
return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgId;
|
let orgId;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import prisma from "@calcom/prisma";
|
|||||||
import { BookingStatus } from "@calcom/prisma/client";
|
import { BookingStatus } from "@calcom/prisma/client";
|
||||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { addFilter, openFilter } from "./filter-helpers";
|
||||||
import { createTeamEventType } from "./fixtures/users";
|
import { createTeamEventType } from "./fixtures/users";
|
||||||
import type { Fixtures } from "./lib/fixtures";
|
import type { Fixtures } from "./lib/fixtures";
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
@@ -76,9 +77,8 @@ test.describe("Bookings", () => {
|
|||||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||||
await bookingsGetResponse;
|
await bookingsGetResponse;
|
||||||
|
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
await openFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
|
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
|
||||||
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
|
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
|
||||||
@@ -243,9 +243,8 @@ test.describe("Bookings", () => {
|
|||||||
await page.goto(`/bookings/past`);
|
await page.goto(`/bookings/past`);
|
||||||
await bookingsGetResponse;
|
await bookingsGetResponse;
|
||||||
|
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
await openFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
|
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
|
||||||
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
|
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
|
||||||
@@ -354,9 +353,8 @@ test.describe("Bookings", () => {
|
|||||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||||
await bookingsGetResponse;
|
await bookingsGetResponse;
|
||||||
|
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "userId");
|
||||||
await page.locator('[data-testid="add-filter-item-userId"]').click();
|
await openFilter(page, "userId");
|
||||||
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
|
|
||||||
|
|
||||||
const bookingsGetResponse2 = page.waitForResponse(
|
const bookingsGetResponse2 = page.waitForResponse(
|
||||||
(response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200
|
(response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200
|
||||||
@@ -461,9 +459,8 @@ test.describe("Bookings", () => {
|
|||||||
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
|
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
|
||||||
await bookingsGetResponse1;
|
await bookingsGetResponse1;
|
||||||
|
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "userId");
|
||||||
await page.locator('[data-testid="add-filter-item-userId"]').click();
|
await openFilter(page, "userId");
|
||||||
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
|
|
||||||
await page
|
await page
|
||||||
.locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${anotherUser}")`)
|
.locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${anotherUser}")`)
|
||||||
.click();
|
.click();
|
||||||
|
|||||||
@@ -2,25 +2,72 @@ import type { Page } from "@playwright/test";
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a filter from the filter dropdown
|
* Add a filter from the filter dropdown
|
||||||
*/
|
*/
|
||||||
export async function selectFilter(page: Page, columnId: string) {
|
export async function addFilter(page: Page, columnId: string) {
|
||||||
await page.getByTestId("add-filter-button").click();
|
await page.getByTestId("add-filter-button").click();
|
||||||
await page.getByTestId(`add-filter-item-${columnId}`).click();
|
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
|
* 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}`);
|
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||||
if (!(await existingFilter.isVisible())) {
|
if (!(await existingFilter.isVisible())) {
|
||||||
await selectFilter(page, columnId);
|
await addFilter(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();
|
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 addFilter(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 addFilter(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");
|
await page.keyboard.press("Escape");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
|||||||
import { MembershipRole } from "@calcom/prisma/enums";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyFilter,
|
applySelectFilter,
|
||||||
createFilterSegment,
|
createFilterSegment,
|
||||||
selectSegment,
|
selectSegment,
|
||||||
deleteSegment,
|
deleteSegment,
|
||||||
@@ -56,7 +56,7 @@ test.describe("Filter Segment Functionality", () => {
|
|||||||
const segmentName = "Admin Users";
|
const segmentName = "Admin Users";
|
||||||
|
|
||||||
await test.step("Can apply and save a role filter as a segment", async () => {
|
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(adminUser.email)).toBeVisible();
|
||||||
await expect(page.getByText(memberUser.email)).toBeHidden();
|
await expect(page.getByText(memberUser.email)).toBeHidden();
|
||||||
@@ -110,7 +110,7 @@ test.describe("Filter Segment Functionality", () => {
|
|||||||
const dataTable = page.getByTestId("user-list-data-table");
|
const dataTable = page.getByTestId("user-list-data-table");
|
||||||
await expect(dataTable).toBeVisible();
|
await expect(dataTable).toBeVisible();
|
||||||
|
|
||||||
await applyFilter(page, "role", "admin");
|
await applySelectFilter(page, "role", "admin");
|
||||||
const segmentName = "Admin Users Persistent";
|
const segmentName = "Admin Users Persistent";
|
||||||
await createFilterSegment(page, segmentName);
|
await createFilterSegment(page, segmentName);
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ test.describe("Filter Segment Functionality", () => {
|
|||||||
const segmentName = "Team Admin Filter";
|
const segmentName = "Team Admin Filter";
|
||||||
|
|
||||||
await test.step("Can create a team scope filter segment", async () => {
|
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, {
|
await createFilterSegment(page, segmentName, {
|
||||||
teamScope: true,
|
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 {
|
||||||
|
addFilter,
|
||||||
|
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 addFilter(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 addFilter(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 addFilter(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
|
|||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
@@ -202,14 +203,13 @@ test.describe("Insights", async () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
// Choose User filter item from dropdown
|
// Choose User filter item from dropdown
|
||||||
await page.getByTestId("add-filter-button").click();
|
await addFilter(page, "bookingUserId");
|
||||||
await page.getByTestId("add-filter-item-bookingUserId").click();
|
|
||||||
|
|
||||||
// Wait for the URL to include bookingUserId
|
// Wait for the URL to include bookingUserId
|
||||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
||||||
|
|
||||||
// Click User filter to see a user list
|
// Click User filter to see a user list
|
||||||
await page.getByTestId("filter-popover-trigger-bookingUserId").click();
|
await openFilter(page, "bookingUserId");
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||||
@@ -226,7 +226,7 @@ test.describe("Insights", async () => {
|
|||||||
// press escape button to close the filter
|
// press escape button to close the filter
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await page.getByTestId("clear-filters-button").click();
|
await clearFilters(page);
|
||||||
|
|
||||||
await expect(page.url()).not.toContain("bookingUserId");
|
await expect(page.url()).not.toContain("bookingUserId");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ test.describe("Bookings", () => {
|
|||||||
// Second booking - should get a different host
|
// Second booking - should get a different host
|
||||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
await bookTimeSlot(page);
|
await bookTimeSlot(page, { email: "attendee1@test.com" });
|
||||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||||
const secondHost = await page.getByTestId("booking-host-name").textContent();
|
const secondHost = await page.getByTestId("booking-host-name").textContent();
|
||||||
expect(secondHost).not.toBeNull();
|
expect(secondHost).not.toBeNull();
|
||||||
@@ -342,7 +342,7 @@ test.describe("Bookings", () => {
|
|||||||
// Third booking - should get a different host
|
// Third booking - should get a different host
|
||||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
await bookTimeSlot(page);
|
await bookTimeSlot(page, { email: "attendee2@test.com" });
|
||||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||||
const thirdHost = await page.getByTestId("booking-host-name").textContent();
|
const thirdHost = await page.getByTestId("booking-host-name").textContent();
|
||||||
expect(thirdHost).not.toBeNull();
|
expect(thirdHost).not.toBeNull();
|
||||||
@@ -352,7 +352,7 @@ test.describe("Bookings", () => {
|
|||||||
// Fourth booking - should get a different host
|
// Fourth booking - should get a different host
|
||||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
await bookTimeSlot(page);
|
await bookTimeSlot(page, { email: "attendee3@test.com" });
|
||||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||||
const fourthHost = await page.getByTestId("booking-host-name").textContent();
|
const fourthHost = await page.getByTestId("booking-host-name").textContent();
|
||||||
expect(fourthHost).not.toBeNull();
|
expect(fourthHost).not.toBeNull();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { randomString } from "@calcom/lib/random";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { MembershipRole } from "@calcom/prisma/enums";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { addFilter, openFilter } from "./filter-helpers";
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
import { localize } from "./lib/localize";
|
import { localize } from "./lib/localize";
|
||||||
import { submitAndWaitForResponse } from "./lib/testUtils";
|
import { submitAndWaitForResponse } from "./lib/testUtils";
|
||||||
@@ -657,8 +658,7 @@ test.describe("Out of office", () => {
|
|||||||
await page.waitForResponse(
|
await page.waitForResponse(
|
||||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
|
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -672,9 +672,8 @@ test.describe("Out of office", () => {
|
|||||||
await page.waitForResponse(
|
await page.waitForResponse(
|
||||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
await openFilter(page, "dateRange");
|
||||||
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
|
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
|
||||||
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible(); //Last 7 Days
|
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible(); //Last 7 Days
|
||||||
@@ -777,11 +776,10 @@ test.describe("Out of office", () => {
|
|||||||
|
|
||||||
//Default filter 'Last 7 Days' when DateRange Filter is selected
|
//Default filter 'Last 7 Days' when DateRange Filter is selected
|
||||||
await test.step("Default filter - 'Last 7 Days'", async () => {
|
await test.step("Default filter - 'Last 7 Days'", async () => {
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "dateRange");
|
||||||
const entriesListRespPromise = page.waitForResponse(
|
const entriesListRespPromise = page.waitForResponse(
|
||||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
|
||||||
await entriesListRespPromise;
|
await entriesListRespPromise;
|
||||||
|
|
||||||
//1 OOO record should be visible for member3, end=currentDate-4days
|
//1 OOO record should be visible for member3, end=currentDate-4days
|
||||||
@@ -839,14 +837,13 @@ test.describe("Out of office", () => {
|
|||||||
|
|
||||||
//Select 'Last 30 Days'
|
//Select 'Last 30 Days'
|
||||||
await test.step("select 'Last 30 Days'", async () => {
|
await test.step("select 'Last 30 Days'", async () => {
|
||||||
await page.locator('[data-testid="add-filter-button"]').click();
|
await addFilter(page, "dateRange");
|
||||||
const entriesListRespPromise1 = page.waitForResponse(
|
const entriesListRespPromise1 = page.waitForResponse(
|
||||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||||
);
|
);
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
|
||||||
await entriesListRespPromise1;
|
await entriesListRespPromise1;
|
||||||
|
|
||||||
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
|
await openFilter(page, "dateRange");
|
||||||
const entriesListRespPromise2 = page.waitForResponse(
|
const entriesListRespPromise2 = page.waitForResponse(
|
||||||
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1599,6 +1599,7 @@
|
|||||||
"team_url": "Team URL",
|
"team_url": "Team URL",
|
||||||
"team_members": "Team members",
|
"team_members": "Team members",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
|
"and_count_more": "and {{count}} more",
|
||||||
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complicated actions, please refer back to the web application.",
|
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complicated actions, please refer back to the web application.",
|
||||||
"workflow_example_1": "Send SMS reminder 24 hours before event starts to attendee",
|
"workflow_example_1": "Send SMS reminder 24 hours before event starts to attendee",
|
||||||
"workflow_example_2": "Send custom SMS when event is rescheduled to attendee",
|
"workflow_example_2": "Send custom SMS when event is rescheduled to attendee",
|
||||||
@@ -1779,6 +1780,7 @@
|
|||||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||||
"attendee_email_variable": "Attendee email",
|
"attendee_email_variable": "Attendee email",
|
||||||
"attendee_email_info": "The person booking's email",
|
"attendee_email_info": "The person booking's email",
|
||||||
|
"booking_uid": "Booking UID",
|
||||||
"kbar_search_placeholder": "Type a command or search...",
|
"kbar_search_placeholder": "Type a command or search...",
|
||||||
"invalid_credential": "It looks like permissions expired or were revoked for {{appName}}.",
|
"invalid_credential": "It looks like permissions expired or were revoked for {{appName}}.",
|
||||||
"invalid_credential_action": "Reinstall app",
|
"invalid_credential_action": "Reinstall app",
|
||||||
@@ -3220,5 +3222,7 @@
|
|||||||
"requires_credits": "Requires credits",
|
"requires_credits": "Requires credits",
|
||||||
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
||||||
"available_credits": "Available credits",
|
"available_credits": "Available credits",
|
||||||
|
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||||
|
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,18 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"functions": {
|
"functions": {
|
||||||
|
"app/api/cron/workflows/scheduleEmailReminders/route.ts": {
|
||||||
|
"maxDuration": 800
|
||||||
|
},
|
||||||
|
"app/api/cron/workflows/scheduleSMSReminders/route.ts": {
|
||||||
|
"maxDuration": 800
|
||||||
|
},
|
||||||
|
"app/api/cron/workflows/scheduleWhatsappReminders/route.ts": {
|
||||||
|
"maxDuration": 800
|
||||||
|
},
|
||||||
|
"pages/api/trpc/workflows/[trpc].ts": {
|
||||||
|
"maxDuration": 800
|
||||||
|
},
|
||||||
"pages/api/trpc/highPerf/[trpc].ts": {
|
"pages/api/trpc/highPerf/[trpc].ts": {
|
||||||
"memory": 3008
|
"memory": 3008
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ title: "Build a greeter app"
|
|||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { Button } from "@calcom/ui/button/Button";
|
import { Button } from "@calcom/ui/button/Button";
|
||||||
|
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
|
|
||||||
export default function GreeterButton() {
|
export default function GreeterButton() {
|
||||||
const { data: user } = useMeQuery();
|
const { data: user } = useMeQuery();
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
|||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import type { AnalyticsService, AnalyticsServiceClass } from "@calcom/types/AnalyticsService";
|
||||||
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||||
|
|
||||||
|
import appStore from "..";
|
||||||
|
|
||||||
|
const log = logger.getSubLogger({ prefix: ["AnalyticsManager"] });
|
||||||
|
|
||||||
|
interface AnalyticsApp {
|
||||||
|
lib: {
|
||||||
|
AnalyticsService: AnalyticsServiceClass;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAnalyticsService = (x: unknown): x is AnalyticsApp =>
|
||||||
|
!!x &&
|
||||||
|
typeof x === "object" &&
|
||||||
|
"lib" in x &&
|
||||||
|
typeof x.lib === "object" &&
|
||||||
|
!!x.lib &&
|
||||||
|
"AnalyticsService" in x.lib;
|
||||||
|
|
||||||
|
export const getAnalyticsService = async ({
|
||||||
|
credential,
|
||||||
|
}: {
|
||||||
|
credential: CredentialPayload;
|
||||||
|
}): Promise<AnalyticsService | null> => {
|
||||||
|
if (!credential || !credential.key) return null;
|
||||||
|
const { type: analyticsType } = credential;
|
||||||
|
|
||||||
|
const analyticsName = analyticsType.split("_")[0];
|
||||||
|
|
||||||
|
const analyticsAppImportFn = appStore[analyticsName as keyof typeof appStore];
|
||||||
|
|
||||||
|
if (!analyticsAppImportFn) {
|
||||||
|
log.warn(`analytics app not implemented`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsApp = await analyticsAppImportFn();
|
||||||
|
|
||||||
|
if (!isAnalyticsService(analyticsApp)) {
|
||||||
|
log.warn(`Analytics is not implemented`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalyticsService = analyticsApp.lib.AnalyticsService;
|
||||||
|
log.info("Got analyticsApp", AnalyticsService);
|
||||||
|
|
||||||
|
return new AnalyticsService(credential);
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ import deel_config_json from "./deel/config.json";
|
|||||||
import demodesk_config_json from "./demodesk/config.json";
|
import demodesk_config_json from "./demodesk/config.json";
|
||||||
import dialpad_config_json from "./dialpad/config.json";
|
import dialpad_config_json from "./dialpad/config.json";
|
||||||
import discord_config_json from "./discord/config.json";
|
import discord_config_json from "./discord/config.json";
|
||||||
|
import dub_config_json from "./dub/config.json";
|
||||||
import eightxeight_config_json from "./eightxeight/config.json";
|
import eightxeight_config_json from "./eightxeight/config.json";
|
||||||
import element_call_config_json from "./element-call/config.json";
|
import element_call_config_json from "./element-call/config.json";
|
||||||
import elevenlabs_config_json from "./elevenlabs/config.json";
|
import elevenlabs_config_json from "./elevenlabs/config.json";
|
||||||
@@ -127,6 +128,7 @@ export const appStoreMetadata = {
|
|||||||
demodesk: demodesk_config_json,
|
demodesk: demodesk_config_json,
|
||||||
dialpad: dialpad_config_json,
|
dialpad: dialpad_config_json,
|
||||||
discord: discord_config_json,
|
discord: discord_config_json,
|
||||||
|
dub: dub_config_json,
|
||||||
eightxeight: eightxeight_config_json,
|
eightxeight: eightxeight_config_json,
|
||||||
"element-call": element_call_config_json,
|
"element-call": element_call_config_json,
|
||||||
elevenlabs: elevenlabs_config_json,
|
elevenlabs: elevenlabs_config_json,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const apiHandlers = {
|
|||||||
demodesk: import("./demodesk/api"),
|
demodesk: import("./demodesk/api"),
|
||||||
dialpad: import("./dialpad/api"),
|
dialpad: import("./dialpad/api"),
|
||||||
discord: import("./discord/api"),
|
discord: import("./discord/api"),
|
||||||
|
dub: import("./dub/api"),
|
||||||
eightxeight: import("./eightxeight/api"),
|
eightxeight: import("./eightxeight/api"),
|
||||||
"element-call": import("./element-call/api"),
|
"element-call": import("./element-call/api"),
|
||||||
elevenlabs: import("./elevenlabs/api"),
|
elevenlabs: import("./elevenlabs/api"),
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
items:
|
||||||
|
- 1.jpeg
|
||||||
|
- 2.jpeg
|
||||||
|
- 3.jpeg
|
||||||
|
- 4.jpeg
|
||||||
|
---
|
||||||
|
|
||||||
|
{DESCRIPTION}
|
||||||
|
|
||||||
|
## How to Get Started
|
||||||
|
|
||||||
|
1. **Log in** to your [Dub.co](https://dub.co) account by installing the app.
|
||||||
|
2. **Set up the [Client SDK](https://dub.co/docs/sdks/client-side/introduction)** on your website:
|
||||||
|
- Add `app.cal.com` to your [Outbound Domains](https://dub.co/docs/sdks/client-side/features/cross-domain-tracking).
|
||||||
|
3. Once a **booking event** occurs, your tracking data will be captured automatically.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
|
|
||||||
|
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
|
||||||
|
import { dubAppKeysSchema, scopeString } from "../lib/utils";
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const loggedInUser = req.session?.user;
|
||||||
|
|
||||||
|
if (!loggedInUser) {
|
||||||
|
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ideally this should never happen, as email is there in session user but typings aren't accurate it seems
|
||||||
|
// TODO: So, confirm and later fix the typings
|
||||||
|
if (!loggedInUser.email) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: "Session user must have an email" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client_id, redirect_uris } = await getParsedAppKeysFromSlug("dub", dubAppKeysSchema);
|
||||||
|
|
||||||
|
const url = new URL("https://app.dub.co/oauth/authorize");
|
||||||
|
url.searchParams.append("client_id", client_id);
|
||||||
|
url.searchParams.append("redirect_uri", redirect_uris);
|
||||||
|
url.searchParams.append("response_type", "code");
|
||||||
|
url.searchParams.append("scope", scopeString);
|
||||||
|
const oauthUrl = url.toString();
|
||||||
|
|
||||||
|
return res.status(200).json({ url: oauthUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultHandler({
|
||||||
|
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||||
|
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
|
||||||
|
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
|
||||||
|
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||||
|
import { dubAppKeysSchema } from "../lib/utils";
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { code } = req.query;
|
||||||
|
|
||||||
|
const state = decodeOAuthState(req);
|
||||||
|
|
||||||
|
if (typeof code !== "string") {
|
||||||
|
if (state?.onErrorReturnTo || state?.returnTo) {
|
||||||
|
res.redirect(
|
||||||
|
getSafeRedirectUrl(state.onErrorReturnTo) ??
|
||||||
|
getSafeRedirectUrl(state?.returnTo) ??
|
||||||
|
`${WEBAPP_URL}/apps/installed`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new HttpError({ statusCode: 400, message: "`code` must be a string" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.session?.user?.id) {
|
||||||
|
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client_id, redirect_uris, client_secret } = await getParsedAppKeysFromSlug("dub", dubAppKeysSchema);
|
||||||
|
|
||||||
|
const codeExchangeUrl = `https://api.dub.co/oauth/token`;
|
||||||
|
|
||||||
|
const result = await fetch(codeExchangeUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id,
|
||||||
|
redirect_uri: redirect_uris,
|
||||||
|
client_secret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
}).toString(),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 200) {
|
||||||
|
let errorMessage = "Something wrong with Dub Api";
|
||||||
|
try {
|
||||||
|
const responseBody = await result.json();
|
||||||
|
if (typeof responseBody?.error?.message === "string") {
|
||||||
|
errorMessage = responseBody.error.message;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ message: errorMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = await result.json();
|
||||||
|
|
||||||
|
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
|
||||||
|
delete responseBody.expires_in;
|
||||||
|
|
||||||
|
await createOAuthAppCredential({ appId: "dub", type: "dub" }, responseBody, req);
|
||||||
|
|
||||||
|
res.redirect(
|
||||||
|
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "analytics", slug: "dub" })
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as add } from "./add";
|
||||||
|
export { default as callback } from "./callback";
|
||||||
Whitespace-only changes.
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||||
|
"name": "Dub",
|
||||||
|
"slug": "dub",
|
||||||
|
"type": "dub_analytics",
|
||||||
|
"logo": "icon.svg",
|
||||||
|
"url": "https://dub.co",
|
||||||
|
"variant": "analytics",
|
||||||
|
"categories": ["analytics"],
|
||||||
|
"publisher": "Cal.com",
|
||||||
|
"email": "help@cal.com",
|
||||||
|
"description": "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs.",
|
||||||
|
"isTemplate": false,
|
||||||
|
"__createdUsingCli": true,
|
||||||
|
"__template": "basic"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * as api from "./api";
|
||||||
|
export * as lib from "./lib";
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Dub } from "dub-package";
|
||||||
|
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||||
|
import type { AnalyticsService, SendEventProps } from "@calcom/types/AnalyticsService";
|
||||||
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||||
|
|
||||||
|
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||||
|
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||||
|
import type { DubOAuthToken } from "./type";
|
||||||
|
|
||||||
|
export default class DubService implements AnalyticsService {
|
||||||
|
private dubClient?: Dub;
|
||||||
|
private client_id = "";
|
||||||
|
private client_secret = "";
|
||||||
|
private log = logger.getSubLogger({ prefix: ["[[lib]] dub"] });
|
||||||
|
private credential: CredentialPayload;
|
||||||
|
|
||||||
|
constructor(credential: CredentialPayload) {
|
||||||
|
this.credential = credential;
|
||||||
|
this.client_id = "";
|
||||||
|
this.client_secret = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initClient() {
|
||||||
|
const appKeys = await getAppKeysFromSlug("dub");
|
||||||
|
|
||||||
|
const { client_id, client_secret } = appKeys;
|
||||||
|
|
||||||
|
if (!client_id || !client_secret) {
|
||||||
|
this.log.error("Dub.co app keys missing!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client_id = client_id as string;
|
||||||
|
this.client_secret = client_secret as string;
|
||||||
|
|
||||||
|
let token = this.credential.key as unknown as DubOAuthToken | undefined;
|
||||||
|
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const isTokenExpired = (token: DubOAuthToken) => {
|
||||||
|
if (!token || !token.access_token) return true;
|
||||||
|
if (token.expiry_date) {
|
||||||
|
return token.expiry_date < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTokenExpired(token)) {
|
||||||
|
token = await this.refreshAccessToken(token.refresh_token);
|
||||||
|
if (!token) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dubClient = new Dub({ token: token.access_token });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAccessToken(refreshToken: string): Promise<DubOAuthToken | undefined> {
|
||||||
|
try {
|
||||||
|
if (!refreshToken) return;
|
||||||
|
const newToken: DubOAuthToken = await refreshOAuthTokens(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`https://api.dub.co/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: this.client_id,
|
||||||
|
client_secret: this.client_secret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}).toString(),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
"dub",
|
||||||
|
this.credential.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
newToken.expiry_date = Date.now() + newToken.expires_in * 1000;
|
||||||
|
|
||||||
|
await CredentialRepository.updateCredentialById({
|
||||||
|
id: this.credential.id,
|
||||||
|
data: { key: newToken as any },
|
||||||
|
});
|
||||||
|
|
||||||
|
return newToken;
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEvent({ name, email, eventName, id, externalId }: SendEventProps): Promise<void> {
|
||||||
|
await this.initClient();
|
||||||
|
|
||||||
|
if (!this.dubClient) return;
|
||||||
|
|
||||||
|
await this.dubClient.track.lead({
|
||||||
|
clickId: id,
|
||||||
|
customerName: name,
|
||||||
|
customerEmail: email,
|
||||||
|
externalId: externalId ?? email,
|
||||||
|
eventName: eventName ?? "Cal.com lead",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as AnalyticsService } from "./AnalyticsService";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface DubOAuthToken {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: "Bearer";
|
||||||
|
expires_in: number;
|
||||||
|
expiry_date?: number;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const dubAppKeysSchema = z.object({
|
||||||
|
client_id: z.string(),
|
||||||
|
client_secret: z.string(),
|
||||||
|
redirect_uris: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dubScope = ["workspaces.read"];
|
||||||
|
|
||||||
|
export const scopeString = dubScope.join(",");
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
|
"private": true,
|
||||||
|
"name": "@calcom/dub",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"@calcom/lib": "*",
|
||||||
|
"dub-package": "npm:dub@^0.61.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@calcom/types": "*"
|
||||||
|
},
|
||||||
|
"description": "Dub is the modern link attribution platform for you to create short links, track conversion analytics, and run affiliate programs."
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 64c17.673 0 32-14.327 32-32 0-11.844-6.435-22.186-16-27.719V48h-8v-2.14A15.9 15.9 0 0 1 32 48c-8.837 0-16-7.163-16-16s7.163-16 16-16c2.914 0 5.647.78 8 2.14V1.008A32 32 0 0 0 32 0C14.327 0 0 14.327 0 32s14.327 32 32 32" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 387 B |
@@ -974,15 +974,15 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
|
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
let error: string | undefined;
|
let error: string | null = null;
|
||||||
|
|
||||||
if (!otherCalendarsWithSameSubscription.length) {
|
if (!otherCalendarsWithSameSubscription.length) {
|
||||||
try {
|
try {
|
||||||
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
|
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
this.log.error(`Failed to watch calendar ${calendarId}`, error);
|
this.log.error(`Failed to watch calendar ${calendarId}`, _error);
|
||||||
// We set error to prevent attempting to watch on next cron run
|
// 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 {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const appStore = {
|
|||||||
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
|
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
|
||||||
closecom: createCachedImport(() => import("./closecom")),
|
closecom: createCachedImport(() => import("./closecom")),
|
||||||
dailyvideo: createCachedImport(() => import("./dailyvideo")),
|
dailyvideo: createCachedImport(() => import("./dailyvideo")),
|
||||||
|
dub: createCachedImport(() => import("./dub")),
|
||||||
googlecalendar: createCachedImport(() => import("./googlecalendar")),
|
googlecalendar: createCachedImport(() => import("./googlecalendar")),
|
||||||
googlevideo: createCachedImport(() => import("./googlevideo")),
|
googlevideo: createCachedImport(() => import("./googlevideo")),
|
||||||
hubspot: createCachedImport(() => import("./hubspot")),
|
hubspot: createCachedImport(() => import("./hubspot")),
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { useFormContext } from "react-hook-form";
|
|||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RoutingFormWithResponseCount } from "@calcom/routing-forms/types/types";
|
import type { RoutingFormWithResponseCount } from "@calcom/routing-forms/types/types";
|
||||||
import { trpc } from "@calcom/trpc";
|
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { DropdownMenuSeparator } from "@calcom/ui/components/dropdown";
|
import { DropdownMenuSeparator } from "@calcom/ui/components/dropdown";
|
||||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import { enabledIncompleteBookingApps } from "../../lib/enabledIncompleteBookingApps";
|
|
||||||
import { FormAction, FormActionsDropdown } from "../FormActions";
|
import { FormAction, FormActionsDropdown } from "../FormActions";
|
||||||
import { FormSettingsSlideover } from "./FormSettingsSlideover";
|
import { FormSettingsSlideover } from "./FormSettingsSlideover";
|
||||||
|
|
||||||
@@ -111,7 +109,6 @@ const Actions = ({
|
|||||||
StartIcon="external-link">
|
StartIcon="external-link">
|
||||||
{t("view_form")}
|
{t("view_form")}
|
||||||
</FormAction>
|
</FormAction>
|
||||||
{isMobile ? (
|
|
||||||
<FormAction
|
<FormAction
|
||||||
action="incompleteBooking"
|
action="incompleteBooking"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -121,7 +118,6 @@ const Actions = ({
|
|||||||
StartIcon="calendar">
|
StartIcon="calendar">
|
||||||
{t("routing_incomplete_booking_tab")}
|
{t("routing_incomplete_booking_tab")}
|
||||||
</FormAction>
|
</FormAction>
|
||||||
) : null}
|
|
||||||
<FormAction
|
<FormAction
|
||||||
action="copyLink"
|
action="copyLink"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -223,14 +219,6 @@ export function Header({
|
|||||||
const [title, setTitle] = useState(routingForm.name);
|
const [title, setTitle] = useState(routingForm.name);
|
||||||
const form = useFormContext<RoutingFormWithResponseCount>();
|
const form = useFormContext<RoutingFormWithResponseCount>();
|
||||||
|
|
||||||
const { data } = trpc.viewer.appRoutingForms.getIncompleteBookingSettings.useQuery({
|
|
||||||
formId: routingForm.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showIncompleteBookingTab = data?.credentials.some((credential) =>
|
|
||||||
enabledIncompleteBookingApps.includes(credential?.appId ?? "")
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getCurrentPage, handleNavigation } = useRoutingFormNavigation(
|
const { getCurrentPage, handleNavigation } = useRoutingFormNavigation(
|
||||||
routingForm,
|
routingForm,
|
||||||
appUrl,
|
appUrl,
|
||||||
@@ -357,15 +345,6 @@ export function Header({
|
|||||||
label: t("routing"),
|
label: t("routing"),
|
||||||
iconLeft: <Icon name="waypoints" className="h-3 w-3" />,
|
iconLeft: <Icon name="waypoints" className="h-3 w-3" />,
|
||||||
},
|
},
|
||||||
...(showIncompleteBookingTab
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
value: "incomplete-booking",
|
|
||||||
label: t("routing_incomplete_booking_tab"),
|
|
||||||
iconLeft: <Icon name="calendar" className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import type { ChangeOfEmailVerifyLink } from "./templates/change-account-email-v
|
|||||||
import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify";
|
import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify";
|
||||||
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
|
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
|
||||||
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
|
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
|
||||||
|
import DelegationCredentialDisabledEmail from "./templates/delegation-credential-disabled-email";
|
||||||
import DisabledAppEmail from "./templates/disabled-app-email";
|
import DisabledAppEmail from "./templates/disabled-app-email";
|
||||||
import type { Feedback } from "./templates/feedback-email";
|
import type { Feedback } from "./templates/feedback-email";
|
||||||
import FeedbackEmail from "./templates/feedback-email";
|
import FeedbackEmail from "./templates/feedback-email";
|
||||||
@@ -835,3 +836,25 @@ export const sendCreditBalanceLimitReachedEmails = async ({
|
|||||||
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user }));
|
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendDelegationCredentialDisabledEmail = async ({
|
||||||
|
recipientEmail,
|
||||||
|
recipientName,
|
||||||
|
calendarAppName,
|
||||||
|
conferencingAppName,
|
||||||
|
}: {
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName?: string;
|
||||||
|
calendarAppName: string;
|
||||||
|
conferencingAppName: string;
|
||||||
|
}) => {
|
||||||
|
await sendEmail(
|
||||||
|
() =>
|
||||||
|
new DelegationCredentialDisabledEmail({
|
||||||
|
recipientEmail,
|
||||||
|
recipientName,
|
||||||
|
calendarAppName,
|
||||||
|
conferencingAppName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
|
||||||
|
import BaseEmail from "./_base-email";
|
||||||
|
|
||||||
|
export default class DelegationCredentialDisabledEmail extends BaseEmail {
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName?: string;
|
||||||
|
calendarAppName: string;
|
||||||
|
conferencingAppName: string;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
recipientEmail,
|
||||||
|
recipientName,
|
||||||
|
calendarAppName,
|
||||||
|
conferencingAppName,
|
||||||
|
}: {
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName?: string;
|
||||||
|
calendarAppName: string;
|
||||||
|
conferencingAppName: string;
|
||||||
|
}) {
|
||||||
|
super();
|
||||||
|
this.name = "DELEGATION_CREDENTIAL_DISABLED";
|
||||||
|
this.recipientEmail = recipientEmail;
|
||||||
|
this.recipientName = recipientName;
|
||||||
|
this.calendarAppName = calendarAppName;
|
||||||
|
this.conferencingAppName = conferencingAppName;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||||
|
to: this.recipientEmail,
|
||||||
|
subject: `You might need to connect your ${this.calendarAppName}`,
|
||||||
|
html: await this.getHtml(),
|
||||||
|
text: this.getTextBody(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHtml() {
|
||||||
|
return `
|
||||||
|
<div style="font-family: Arial, sans-serif;">
|
||||||
|
<h2>Action Required: Connect your ${this.calendarAppName}</h2>
|
||||||
|
<p>
|
||||||
|
${this.recipientName ? `Hi ${this.recipientName},` : "Hello,"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
An admin has disabled Delegation Credential for your organization. You may need to connect your <b>${
|
||||||
|
this.calendarAppName
|
||||||
|
}</b> to use Cal.com effectively. Connecting your calendar is crucial as it allows us to check for conflicts to prevent double bookings and automatically add new events to your schedule.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please log in to your Cal.com account and connect your calendar from your settings page by visiting: <a href="${WEBAPP_URL}/settings/my-account/calendars">${WEBAPP_URL}/settings/my-account/calendars</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Additionally, please ensure you have the ${
|
||||||
|
this.conferencingAppName
|
||||||
|
} app installed. You can manage your conferencing apps here: <a href="${WEBAPP_URL}/settings/my-account/conferencing">${WEBAPP_URL}/settings/my-account/conferencing</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you have already connected your calendar and installed ${
|
||||||
|
this.conferencingAppName
|
||||||
|
}, you can ignore this message.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Thank you,<br />The Cal.com Team
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getTextBody(): string {
|
||||||
|
return `Action Required: Connect your ${this.calendarAppName}\n\nAn admin has disabled Delegation Credential for your organization. You may need to connect your ${this.calendarAppName} manually to continue receiving bookings and updates, and to allow Cal.com to check for conflicts to prevent double bookings and automatically add new events to your schedule.\n\nPlease log in to your Cal.com account and connect your calendar from your settings page by visiting: ${WEBAPP_URL}/settings/my-account/calendars.\n\nAdditionally, please ensure you have the ${this.conferencingAppName} app installed. You can manage your conferencing apps here: ${WEBAPP_URL}/settings/my-account/conferencing.\n\nIf you have already connected your calendar and installed ${this.conferencingAppName}, you can ignore this message.\n\nThank you,\nThe Cal.com Team`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Dub } from "dub";
|
import { Dub } from "dub-package";
|
||||||
|
|
||||||
export const dub = new Dub({
|
export const dub = new Dub({
|
||||||
token: process.env.DUB_API_KEY,
|
token: process.env.DUB_API_KEY,
|
||||||
|
|||||||
@@ -1053,7 +1053,7 @@ export const getOptions = ({
|
|||||||
dub.track.lead({
|
dub.track.lead({
|
||||||
clickId,
|
clickId,
|
||||||
eventName: "Sign Up",
|
eventName: "Sign Up",
|
||||||
customerId: user.id.toString(),
|
externalId: user.id.toString(),
|
||||||
customerName: user.name,
|
customerName: user.name,
|
||||||
customerEmail: user.email,
|
customerEmail: user.email,
|
||||||
customerAvatar: user.image,
|
customerAvatar: user.image,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { getQueryParam } from "../../utils/query-param";
|
|||||||
|
|
||||||
export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>;
|
export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>;
|
||||||
|
|
||||||
export const useBookerLayout = (event: Pick<BookerEvent, "profile"> | undefined | null) => {
|
export const useBookerLayout = (
|
||||||
|
profileBookerLayouts: BookerEvent["profile"]["bookerLayouts"] | undefined | null
|
||||||
|
) => {
|
||||||
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
|
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
|
||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
@@ -31,7 +33,7 @@ export const useBookerLayout = (event: Pick<BookerEvent, "profile"> | undefined
|
|||||||
const columnViewExtraDays = useRef<number>(
|
const columnViewExtraDays = useRef<number>(
|
||||||
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
|
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
|
||||||
);
|
);
|
||||||
const bookerLayouts = event?.profile?.bookerLayouts || defaultBookerLayoutSettings;
|
const bookerLayouts = profileBookerLayouts || defaultBookerLayoutSettings;
|
||||||
const defaultLayout = isEmbed
|
const defaultLayout = isEmbed
|
||||||
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
|
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
|
||||||
: bookerLayouts.defaultLayout;
|
: bookerLayouts.defaultLayout;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export interface IUseBookingForm {
|
|||||||
guests: string[];
|
guests: string[];
|
||||||
name: string | null;
|
name: string | null;
|
||||||
};
|
};
|
||||||
lastBookingResponse?: Record<string, string>;
|
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ export const useBookingForm = ({
|
|||||||
hasSession,
|
hasSession,
|
||||||
extraOptions,
|
extraOptions,
|
||||||
prefillFormParams,
|
prefillFormParams,
|
||||||
lastBookingResponse,
|
|
||||||
clientId,
|
clientId,
|
||||||
}: IUseBookingForm) => {
|
}: IUseBookingForm) => {
|
||||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||||
@@ -76,7 +74,6 @@ export const useBookingForm = ({
|
|||||||
hasSession,
|
hasSession,
|
||||||
extraOptions,
|
extraOptions,
|
||||||
prefillFormParams,
|
prefillFormParams,
|
||||||
lastBookingResponse,
|
|
||||||
clientId,
|
clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type UseInitialFormValuesProps = {
|
|||||||
guests: string[];
|
guests: string[];
|
||||||
name: string | null;
|
name: string | null;
|
||||||
};
|
};
|
||||||
lastBookingResponse?: Record<string, string>;
|
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ export function useInitialFormValues({
|
|||||||
hasSession,
|
hasSession,
|
||||||
extraOptions,
|
extraOptions,
|
||||||
prefillFormParams,
|
prefillFormParams,
|
||||||
lastBookingResponse,
|
|
||||||
clientId,
|
clientId,
|
||||||
}: UseInitialFormValuesProps) {
|
}: UseInitialFormValuesProps) {
|
||||||
const stableHashExtraOptions = getStableHash(extraOptions);
|
const stableHashExtraOptions = getStableHash(extraOptions);
|
||||||
@@ -105,7 +103,6 @@ export function useInitialFormValues({
|
|||||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||||
guests: prefillFormParams.guests,
|
guests: prefillFormParams.guests,
|
||||||
});
|
});
|
||||||
const parsedLastBookingResponse = z.record(z.any()).nullish().parse(lastBookingResponse);
|
|
||||||
|
|
||||||
const defaultUserValues = {
|
const defaultUserValues = {
|
||||||
email:
|
email:
|
||||||
@@ -113,13 +110,13 @@ export function useInitialFormValues({
|
|||||||
? bookingData?.attendees[0].email
|
? bookingData?.attendees[0].email
|
||||||
: !!parsedQuery["email"]
|
: !!parsedQuery["email"]
|
||||||
? parsedQuery["email"]
|
? parsedQuery["email"]
|
||||||
: email ?? parsedLastBookingResponse?.email ?? "",
|
: email ?? "",
|
||||||
name:
|
name:
|
||||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||||
? bookingData?.attendees[0].name
|
? bookingData?.attendees[0].name
|
||||||
: !!parsedQuery["name"]
|
: !!parsedQuery["name"]
|
||||||
? parsedQuery["name"]
|
? parsedQuery["name"]
|
||||||
: name ?? username ?? parsedLastBookingResponse?.name ?? "",
|
: name ?? username ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
|
|
||||||
import { localStorage } from "@calcom/lib/webstorage";
|
|
||||||
|
|
||||||
import { setLastBookingResponse, getLastBookingResponse } from "./lastBookingResponse";
|
|
||||||
|
|
||||||
vi.mock("@calcom/lib/webstorage", () => ({
|
|
||||||
localStorage: {
|
|
||||||
getItem: vi.fn(),
|
|
||||||
setItem: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe.skip("Last Booking Response", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setLastBookingResponse", () => {
|
|
||||||
it("should not store anything if responses is null", () => {
|
|
||||||
setLastBookingResponse(null);
|
|
||||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should store only allowed responses in localStorage", () => {
|
|
||||||
const responses = {
|
|
||||||
email: "test@example.com",
|
|
||||||
name: "John Doe",
|
|
||||||
age: 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
setLastBookingResponse(responses);
|
|
||||||
|
|
||||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
||||||
"lastBookingResponse",
|
|
||||||
JSON.stringify({ email: "test@example.com", name: "John Doe" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getLastBookingResponse", () => {
|
|
||||||
it("should return an empty object if there is no last booking response", () => {
|
|
||||||
(localStorage.getItem as jest.Mock).mockReturnValue(null);
|
|
||||||
|
|
||||||
const result = getLastBookingResponse();
|
|
||||||
expect(result).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return parsed response if valid JSON is present", () => {
|
|
||||||
const mockResponse = JSON.stringify({ email: "test@example.com", name: "John Doe" });
|
|
||||||
(localStorage.getItem as jest.Mock).mockReturnValue(mockResponse);
|
|
||||||
|
|
||||||
const result = getLastBookingResponse();
|
|
||||||
expect(result).toEqual({ email: "test@example.com", name: "John Doe" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return an empty object if JSON is invalid", () => {
|
|
||||||
const invalidJson = "{ email: 'test@example.com' ";
|
|
||||||
(localStorage.getItem as jest.Mock).mockReturnValue(invalidJson);
|
|
||||||
|
|
||||||
const result = getLastBookingResponse();
|
|
||||||
expect(result).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// const responsesToStore = ["email", "name"];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export const setLastBookingResponse = (responses: Record<string, unknown> | null) => {
|
|
||||||
return;
|
|
||||||
// if (!responses) return;
|
|
||||||
// const prevResponse = Object.fromEntries(
|
|
||||||
// Object.entries(responses).filter(([key]) => responsesToStore.includes(key))
|
|
||||||
// );
|
|
||||||
// localStorage.setItem("lastBookingResponse", JSON.stringify(prevResponse));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLastBookingResponse = () => {
|
|
||||||
return {};
|
|
||||||
// const lastBookingResponse = localStorage.getItem("lastBookingResponse");
|
|
||||||
// try {
|
|
||||||
// return JSON.parse(lastBookingResponse ?? "{}");
|
|
||||||
// } catch (err) {
|
|
||||||
// logger.error("Error parsing lastBookingResponse: ", err);
|
|
||||||
// return {};
|
|
||||||
// }
|
|
||||||
};
|
|
||||||
@@ -60,6 +60,7 @@ export const mapBookingToMutationInput = ({
|
|||||||
const _isDryRun = isBookingDryRun(searchParams);
|
const _isDryRun = isBookingDryRun(searchParams);
|
||||||
const _cacheParam = searchParams?.get("cal.cache");
|
const _cacheParam = searchParams?.get("cal.cache");
|
||||||
const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
|
const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
|
||||||
|
const dub_id = searchParams?.get("dub_id");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...values,
|
...values,
|
||||||
@@ -91,6 +92,7 @@ export const mapBookingToMutationInput = ({
|
|||||||
reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined,
|
reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined,
|
||||||
_isDryRun,
|
_isDryRun,
|
||||||
_shouldServeCache,
|
_shouldServeCache,
|
||||||
|
dub_id,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
} from "@calcom/features/webhooks/lib/scheduleTrigger";
|
} from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||||
import EventManager from "@calcom/lib/EventManager";
|
import EventManager from "@calcom/lib/EventManager";
|
||||||
|
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
|
||||||
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
||||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +66,7 @@ import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFre
|
|||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
|
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||||
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -109,7 +111,6 @@ import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from
|
|||||||
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
|
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
|
||||||
import { validateEventLength } from "./handleNewBooking/validateEventLength";
|
import { validateEventLength } from "./handleNewBooking/validateEventLength";
|
||||||
import handleSeats from "./handleSeats/handleSeats";
|
import handleSeats from "./handleSeats/handleSeats";
|
||||||
import { getBookingRequest } from "./requiresConfirmation/getBookingRequest";
|
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
||||||
@@ -275,17 +276,16 @@ export const buildEventForTeamEventType = async ({
|
|||||||
throw new Error("Scheduling type is required for team event type");
|
throw new Error("Scheduling type is required for team event type");
|
||||||
}
|
}
|
||||||
const teamDestinationCalendars: DestinationCalendar[] = [];
|
const teamDestinationCalendars: DestinationCalendar[] = [];
|
||||||
|
const fixedUsers = users.filter((user) => user.isFixed);
|
||||||
|
const nonFixedUsers = users.filter((user) => !user.isFixed);
|
||||||
|
const filteredUsers =
|
||||||
|
schedulingType === SchedulingType.ROUND_ROBIN
|
||||||
|
? [...fixedUsers, ...(nonFixedUsers.length > 0 ? [nonFixedUsers[0]] : [])]
|
||||||
|
: users;
|
||||||
|
|
||||||
// Organizer or user owner of this event type it's not listed as a team member.
|
// Organizer or user owner of this event type it's not listed as a team member.
|
||||||
const teamMemberPromises = users
|
const teamMemberPromises = filteredUsers
|
||||||
.filter((user) => {
|
.filter((user) => user.email !== organizerUser.email)
|
||||||
if (user.email === organizerUser.email) return false;
|
|
||||||
|
|
||||||
// Skip non-fixed users in ROUND_ROBIN team event
|
|
||||||
if (schedulingType === SchedulingType.ROUND_ROBIN && !user.isFixed) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(async (user) => {
|
.map(async (user) => {
|
||||||
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
|
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
|
||||||
// push to teamDestinationCalendars if it's a team event but collective only
|
// push to teamDestinationCalendars if it's a team event but collective only
|
||||||
@@ -489,20 +489,24 @@ async function handler(
|
|||||||
bookerEmail,
|
bookerEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If a request already exists for the timeslot, return that request
|
// For unconfirmed bookings or round robin bookings with the same attendee and timeslot, return the original booking
|
||||||
if (!isConfirmedByDefault && !userReschedulingIsOwner) {
|
if (
|
||||||
const requestedBooking = await getBookingRequest({
|
(!isConfirmedByDefault && !userReschedulingIsOwner) ||
|
||||||
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
||||||
|
) {
|
||||||
|
const existingBooking = await BookingRepository.getValidBookingFromEventTypeForAttendee({
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
bookerEmail,
|
bookerEmail,
|
||||||
bookerPhoneNumber,
|
bookerPhoneNumber,
|
||||||
startTime: new Date(dayjs(reqBody.start).utc().format()),
|
startTime: new Date(dayjs(reqBody.start).utc().format()),
|
||||||
|
filterForUnconfirmed: !isConfirmedByDefault,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (requestedBooking) {
|
if (existingBooking) {
|
||||||
const bookingResponse = {
|
const bookingResponse = {
|
||||||
...requestedBooking,
|
...existingBooking,
|
||||||
user: {
|
user: {
|
||||||
...requestedBooking.user,
|
...existingBooking.user,
|
||||||
email: null,
|
email: null,
|
||||||
},
|
},
|
||||||
paymentRequired: false,
|
paymentRequired: false,
|
||||||
@@ -2160,6 +2164,18 @@ async function handler(
|
|||||||
loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error }));
|
loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
await handleAnalyticsEvents({
|
||||||
|
credentials: allCredentials,
|
||||||
|
rawBookingData,
|
||||||
|
bookingInfo: {
|
||||||
|
name: fullName,
|
||||||
|
email: bookerEmail,
|
||||||
|
eventName: "Cal.com lead",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Refactor better so this booking object is not passed
|
// TODO: Refactor better so this booking object is not passed
|
||||||
// all around and instead the individual fields are sent as args.
|
// all around and instead the individual fields are sent as args.
|
||||||
const bookingResponse = {
|
const bookingResponse = {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const sortUsersByDynamicList = <TUser extends { username: string | null }>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const _getLocationValuesForDb = <
|
export const _getLocationValuesForDb = <
|
||||||
TUser extends {
|
TUser extends {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
metadata: Prisma.JsonValue;
|
metadata: Prisma.JsonValue;
|
||||||
@@ -42,9 +42,15 @@ const _getLocationValuesForDb = <
|
|||||||
credentials: firstDynamicGroupMember.credentials,
|
credentials: firstDynamicGroupMember.credentials,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultConferencingApp = firstDynamicGroupMemberMetadata?.defaultConferencingApp;
|
||||||
|
|
||||||
|
const hasMemberSetConferencingPreference =
|
||||||
|
!!defaultConferencingApp?.appSlug || !!defaultConferencingApp?.appLink;
|
||||||
|
|
||||||
firstDynamicGroupMemberDefaultLocationUrl =
|
firstDynamicGroupMemberDefaultLocationUrl =
|
||||||
firstDynamicGroupMemberMetadata?.defaultConferencingApp?.appLink ||
|
(hasMemberSetConferencingPreference
|
||||||
firstDynamicGroupMemberDelegationCredentialConferencingAppLocation;
|
? defaultConferencingApp?.appLink
|
||||||
|
: firstDynamicGroupMemberDelegationCredentialConferencingAppLocation) ?? null;
|
||||||
|
|
||||||
locationBodyString = firstDynamicGroupMemberDefaultLocationUrl || locationBodyString;
|
locationBodyString = firstDynamicGroupMemberDefaultLocationUrl || locationBodyString;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-3
@@ -104,11 +104,11 @@ describe("buildEventForTeamEventType", () => {
|
|||||||
expect(withDestinationCalendarArgs).toHaveLength(0);
|
expect(withDestinationCalendarArgs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes non-fixed users for ROUND_ROBIN", async () => {
|
it("includes one non-fixed user for ROUND_ROBIN when fixed users exist", async () => {
|
||||||
await buildEventForTeamEventType({
|
await buildEventForTeamEventType({
|
||||||
existingEvent: {},
|
existingEvent: {},
|
||||||
users: [
|
users: [
|
||||||
baseUser({ id: 2, isFixed: false, email: "notfixed@example.com" }),
|
baseUser({ id: 2, isFixed: false, email: "nonfixed@example.com" }),
|
||||||
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
|
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
|
||||||
],
|
],
|
||||||
organizerUser: { email: "organizer@example.com" },
|
organizerUser: { email: "organizer@example.com" },
|
||||||
@@ -119,7 +119,27 @@ describe("buildEventForTeamEventType", () => {
|
|||||||
const memberEmails = teamArgs.members.map((m: any) => m.email);
|
const memberEmails = teamArgs.members.map((m: any) => m.email);
|
||||||
|
|
||||||
expect(memberEmails).toContain("fixed@example.com");
|
expect(memberEmails).toContain("fixed@example.com");
|
||||||
expect(memberEmails).not.toContain("notfixed@example.com");
|
expect(memberEmails).toContain("nonfixed@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes only the first non-fixed user for ROUND_ROBIN when multiple exist", async () => {
|
||||||
|
await buildEventForTeamEventType({
|
||||||
|
existingEvent: {},
|
||||||
|
users: [
|
||||||
|
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
|
||||||
|
baseUser({ id: 2, isFixed: false, email: "nonfixed1@example.com" }),
|
||||||
|
baseUser({ id: 4, isFixed: false, email: "nonfixed2@example.com" }),
|
||||||
|
],
|
||||||
|
organizerUser: { email: "organizer@example.com" },
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamArgs = withTeamSpy.mock.calls[0][0];
|
||||||
|
const memberEmails = teamArgs.members.map((m: any) => m.email);
|
||||||
|
|
||||||
|
expect(memberEmails).toContain("fixed@example.com");
|
||||||
|
expect(memberEmails).toContain("nonfixed1@example.com");
|
||||||
|
expect(memberEmails).not.toContain("nonfixed2@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds a team with fallback name and id", async () => {
|
it("builds a team with fallback name and id", async () => {
|
||||||
|
|||||||
@@ -925,5 +925,140 @@ describe("handleNewBooking", () => {
|
|||||||
},
|
},
|
||||||
timeout
|
timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
`should use Cal Video as the location if that is the default conferencing app set by the user. It must not use Google Meet coming from Delegation credential.`,
|
||||||
|
async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
|
||||||
|
const org = await createOrganization({
|
||||||
|
name: "Test Org",
|
||||||
|
slug: "testorg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadToMakePartOfOrganization = [
|
||||||
|
{
|
||||||
|
membership: {
|
||||||
|
accepted: true,
|
||||||
|
role: MembershipRole.ADMIN,
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
id: org.id,
|
||||||
|
name: "Test Org",
|
||||||
|
slug: "testorg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupUser1 = getOrganizer({
|
||||||
|
name: "group-user-1",
|
||||||
|
username: "group-user-1",
|
||||||
|
email: "group-user-1@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
teams: payloadToMakePartOfOrganization,
|
||||||
|
credentials: [],
|
||||||
|
destinationCalendar: TestData.selectedCalendars.google,
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "daily-video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupUser2 = getOrganizer({
|
||||||
|
name: "group-user-2",
|
||||||
|
username: "group-user-2",
|
||||||
|
email: "group-user-2@example.com",
|
||||||
|
id: 102,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
teams: payloadToMakePartOfOrganization,
|
||||||
|
credentials: [],
|
||||||
|
destinationCalendar: TestData.selectedCalendars.google,
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "daily-video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createDelegationCredential(org.id);
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [],
|
||||||
|
users: [groupUser1, groupUser2],
|
||||||
|
apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: "dailyvideo",
|
||||||
|
videoMeetingData: {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
password: "MOCK_PASS",
|
||||||
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID
|
||||||
|
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||||
|
create: {
|
||||||
|
id: "GOOGLE_CALENDAR_EVENT_ID",
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
appSpecificData: {
|
||||||
|
googleCalendar: {
|
||||||
|
hangoutLink: "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForDynamicGroupBooking({
|
||||||
|
data: {
|
||||||
|
start: `${plus1DateString}T05:00:00.000Z`,
|
||||||
|
end: `${plus1DateString}T05:30:00.000Z`,
|
||||||
|
eventTypeId: 0,
|
||||||
|
eventTypeSlug: "group-user-1+group-user-2",
|
||||||
|
user: "group-user-1+group-user-2",
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
// location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdBooking = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectBookingToBeInDatabase({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
uid: createdBooking.uid!,
|
||||||
|
eventTypeId: null,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
location: BookingLocations.CalVideo,
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
// Verify Delegation credential was not used
|
||||||
|
delegationCredentialId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
iCalUID: createdBooking.iCalUID,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
timeout
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -58,7 +58,7 @@ import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
|||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||||
import { CreationSource, WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums";
|
import { CreationSource, WatchlistSeverity, WatchlistType } from "@calcom/prisma/enums";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
|
||||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
@@ -3280,6 +3280,126 @@ describe("handleNewBooking", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Returning original booking", () => {
|
||||||
|
test("Return the original booking if a request is made twice by the same attendee when a booking requires confirmation", async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
user: organizer.username,
|
||||||
|
eventTypeId: 1,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: "New York" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarioData = getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 30,
|
||||||
|
length: 30,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
|
const response = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondResponse = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondResponse.id).toEqual(response.id);
|
||||||
|
});
|
||||||
|
test("Return the original booking if request is made by the same attendee for a round robin event", async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
user: organizer.username,
|
||||||
|
eventTypeId: 1,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: "New York" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarioData = getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 30,
|
||||||
|
length: 30,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
|
const response = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondResponse = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondResponse.id).toEqual(response.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Blocked users", () => {
|
describe("Blocked users", () => {
|
||||||
test("user is locked", async () => {
|
test("user is locked", async () => {
|
||||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||||
|
|
||||||
|
import { _getLocationValuesForDb } from "../getLocationValuesForDb";
|
||||||
|
|
||||||
|
vi.mock("@calcom/prisma/zod-utils", () => ({
|
||||||
|
userMetadata: { parse: (metadata: any) => metadata },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/delegationCredential/server", () => ({
|
||||||
|
getFirstDelegationConferencingCredentialAppLocation: ({
|
||||||
|
credentials,
|
||||||
|
}: {
|
||||||
|
credentials: CredentialForCalendarService[];
|
||||||
|
}) => (credentials[0] && (credentials[0] as any).__test__appLink) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type TestUser = {
|
||||||
|
username: string | null;
|
||||||
|
metadata: Prisma.JsonValue;
|
||||||
|
credentials: CredentialForCalendarService[];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("_getLocationValuesForDb", () => {
|
||||||
|
it("returns the provided location for single user bookings", () => {
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{
|
||||||
|
username: "alice",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = _getLocationValuesForDb({
|
||||||
|
dynamicUserList: ["alice"],
|
||||||
|
users,
|
||||||
|
location: "https://meet.example.com/alice",
|
||||||
|
});
|
||||||
|
expect(result.locationBodyString).toBe("https://meet.example.com/alice");
|
||||||
|
expect(result.organizerOrFirstDynamicGroupMemberDefaultLocationUrl).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first group member's default conferencing appLink if set", () => {
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{
|
||||||
|
username: "bob",
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "zoom",
|
||||||
|
appLink: "https://zoom.us/bob",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "carol",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = _getLocationValuesForDb({
|
||||||
|
dynamicUserList: ["bob", "carol"],
|
||||||
|
users,
|
||||||
|
location: "https://meet.example.com/group",
|
||||||
|
});
|
||||||
|
expect(result.locationBodyString).toBe("https://zoom.us/bob");
|
||||||
|
expect(result.organizerOrFirstDynamicGroupMemberDefaultLocationUrl).toBe("https://zoom.us/bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first group member's first delegation credential's conferencing app if no preference is set", () => {
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{
|
||||||
|
username: "dave",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
__test__appLink: "https://meet.google.com/dave",
|
||||||
|
} as unknown as CredentialForCalendarService,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "eve",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = _getLocationValuesForDb({
|
||||||
|
dynamicUserList: ["dave", "eve"],
|
||||||
|
users,
|
||||||
|
location: "https://meet.example.com/group",
|
||||||
|
});
|
||||||
|
expect(result.locationBodyString).toBe("https://meet.google.com/dave");
|
||||||
|
expect(result.organizerOrFirstDynamicGroupMemberDefaultLocationUrl).toBe("https://meet.google.com/dave");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provided location if no preferences or credentials are set", () => {
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{
|
||||||
|
username: "frank",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "grace",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = _getLocationValuesForDb({
|
||||||
|
dynamicUserList: ["frank", "grace"],
|
||||||
|
users,
|
||||||
|
location: "https://meet.example.com/group",
|
||||||
|
});
|
||||||
|
expect(result.locationBodyString).toBe("https://meet.example.com/group");
|
||||||
|
expect(result.organizerOrFirstDynamicGroupMemberDefaultLocationUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provided location if preference is set but has no appLink", () => {
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{
|
||||||
|
username: "frank",
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "daily-video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "grace",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = _getLocationValuesForDb({
|
||||||
|
dynamicUserList: ["frank", "grace"],
|
||||||
|
users,
|
||||||
|
location: "https://meet.example.com/group",
|
||||||
|
});
|
||||||
|
expect(result.locationBodyString).toBe("https://meet.example.com/group");
|
||||||
|
expect(result.organizerOrFirstDynamicGroupMemberDefaultLocationUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts users according to dynamicUserList before picking the first member", () => {
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{
|
||||||
|
username: "zara",
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "teams",
|
||||||
|
appLink: "https://teams.microsoft.com/zara",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "yanni",
|
||||||
|
metadata: {},
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// yanni is first in dynamicUserList, but zara is first in users array
|
||||||
|
const result = _getLocationValuesForDb({
|
||||||
|
dynamicUserList: ["yanni", "zara"],
|
||||||
|
users,
|
||||||
|
location: "https://meet.example.com/group",
|
||||||
|
});
|
||||||
|
// After sorting, yanni is first, has no preference, so fallback to location
|
||||||
|
expect(result.locationBodyString).toBe("https://meet.example.com/group");
|
||||||
|
expect(result.organizerOrFirstDynamicGroupMemberDefaultLocationUrl).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
|
|||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||||
@@ -82,6 +83,13 @@ const handleCalendarsToUnwatch = async () => {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
log.error(
|
||||||
|
"Error unwatching calendar: ",
|
||||||
|
safeStringify({
|
||||||
|
selectedCalendarId: id,
|
||||||
|
error: errorMessage,
|
||||||
|
})
|
||||||
|
);
|
||||||
await SelectedCalendarRepository.updateById(id, {
|
await SelectedCalendarRepository.updateById(id, {
|
||||||
error: `Error unwatching calendar: ${errorMessage}`,
|
error: `Error unwatching calendar: ${errorMessage}`,
|
||||||
});
|
});
|
||||||
@@ -117,6 +125,13 @@ const handleCalendarsToWatch = async () => {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
log.error(
|
||||||
|
"Error watching calendar: ",
|
||||||
|
safeStringify({
|
||||||
|
selectedCalendarId: id,
|
||||||
|
error: errorMessage,
|
||||||
|
})
|
||||||
|
);
|
||||||
await SelectedCalendarRepository.updateById(id, {
|
await SelectedCalendarRepository.updateById(id, {
|
||||||
error: `Error watching calendar: ${errorMessage}`,
|
error: `Error watching calendar: ${errorMessage}`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
|
|||||||
log.debug("Getting cached availability", safeStringify({ credentialId, userId, args }));
|
log.debug("Getting cached availability", safeStringify({ credentialId, userId, args }));
|
||||||
const key = parseKeyForCache(args);
|
const key = parseKeyForCache(args);
|
||||||
let cached;
|
let cached;
|
||||||
|
let usedInMemoryDelegationCredential = false;
|
||||||
if (isInMemoryDelegationCredential({ credentialId })) {
|
if (isInMemoryDelegationCredential({ credentialId })) {
|
||||||
|
usedInMemoryDelegationCredential = true;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
log.warn("userId is not available when querying cache for in-memory delegation credential");
|
log.warn("userId is not available when querying cache for in-memory delegation credential");
|
||||||
return null;
|
return null;
|
||||||
@@ -109,6 +111,11 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
|
|||||||
key,
|
key,
|
||||||
expiresAt: { gte: new Date(Date.now()) },
|
expiresAt: { gte: new Date(Date.now()) },
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
// In case of multiple entries for same key and userId, we prefer the one with highest expiry, which will be the most updated one
|
||||||
|
// TODO: For better tracking we could also want to use updatedAt directly which doesn't exist yet in CalendarCache table
|
||||||
|
expiresAt: "desc",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
cached = await prisma.calendarCache.findUnique({
|
cached = await prisma.calendarCache.findUnique({
|
||||||
@@ -121,7 +128,10 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
log.info("Got cached availability", safeStringify({ key, cached }));
|
log.info(
|
||||||
|
"Got cached availability",
|
||||||
|
safeStringify({ key, cached, credentialId, usedInMemoryDelegationCredential })
|
||||||
|
);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
async upsertCachedAvailability({
|
async upsertCachedAvailability({
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ interface DataTableProviderProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
ctaContainerClassName?: string;
|
ctaContainerClassName?: string;
|
||||||
defaultPageSize?: number;
|
defaultPageSize?: number;
|
||||||
|
segments?: FilterSegmentOutput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableProvider({
|
export function DataTableProvider({
|
||||||
@@ -82,6 +83,7 @@ export function DataTableProvider({
|
|||||||
useSegments = useSegmentsNoop,
|
useSegments = useSegmentsNoop,
|
||||||
defaultPageSize = DEFAULT_PAGE_SIZE,
|
defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||||
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
|
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
|
||||||
|
segments: providedSegments,
|
||||||
}: DataTableProviderProps) {
|
}: DataTableProviderProps) {
|
||||||
const filterToOpen = useRef<string | undefined>(undefined);
|
const filterToOpen = useRef<string | undefined>(undefined);
|
||||||
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||||
@@ -182,6 +184,7 @@ export function DataTableProvider({
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
setPageIndex,
|
setPageIndex,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
|
segments: providedSegments,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ function DataTableBody<TData>({
|
|||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
|
data-testid={`data-table-td-${cell.column.id}`}
|
||||||
style={{
|
style={{
|
||||||
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
|
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
|
||||||
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
|
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-3 my-2">
|
<div className="mx-3 my-2" data-testid={`number-filter-options-${column.id}`}>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
handleSubmit={({ operatorOption, operand }) => {
|
handleSubmit={({ operatorOption, operand }) => {
|
||||||
@@ -51,6 +51,7 @@ export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
|
|||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
|
data-testid={`number-filter-options-select-${column.id}`}
|
||||||
className="basis-1/3"
|
className="basis-1/3"
|
||||||
options={numberFilterOperatorOptions}
|
options={numberFilterOperatorOptions}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function TextFilterOptions({ column }: TextFilterOptionsProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-3 my-2">
|
<div className="mx-3 my-2" data-testid={`text-filter-options-${column.id}`}>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
handleSubmit={({ operatorOption, operand }) => {
|
handleSubmit={({ operatorOption, operand }) => {
|
||||||
@@ -52,6 +52,7 @@ export function TextFilterOptions({ column }: TextFilterOptionsProps) {
|
|||||||
render={({ field: { value } }) => (
|
render={({ field: { value } }) => (
|
||||||
<>
|
<>
|
||||||
<Select
|
<Select
|
||||||
|
data-testid={`text-filter-options-select-${column.id}`}
|
||||||
options={textFilterOperatorOptions}
|
options={textFilterOperatorOptions}
|
||||||
value={value}
|
value={value}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
|||||||
@@ -26,15 +26,24 @@ export const useSegments: UseSegments = ({
|
|||||||
setPageSize,
|
setPageSize,
|
||||||
setPageIndex,
|
setPageIndex,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
|
segments: providedSegments,
|
||||||
}) => {
|
}) => {
|
||||||
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery({
|
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
|
||||||
|
{
|
||||||
tableIdentifier,
|
tableIdentifier,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
enabled: !providedSegments, // Only fetch if segments are not provided
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Recalculate date ranges based on the current timestamp
|
// Recalculate date ranges based on the current timestamp
|
||||||
const segments = useMemo(() => {
|
const segments = useMemo(() => {
|
||||||
if (!rawSegments) return [];
|
const segmentsSource = providedSegments || rawSegments;
|
||||||
return rawSegments.map((segment) => ({
|
|
||||||
|
if (!segmentsSource) return [];
|
||||||
|
|
||||||
|
return segmentsSource.map((segment) => ({
|
||||||
...segment,
|
...segment,
|
||||||
activeFilters: segment.activeFilters.map((filter) => {
|
activeFilters: segment.activeFilters.map((filter) => {
|
||||||
if (isDateRangeFilterValue(filter.v)) {
|
if (isDateRangeFilterValue(filter.v)) {
|
||||||
@@ -46,7 +55,7 @@ export const useSegments: UseSegments = ({
|
|||||||
return filter;
|
return filter;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
}, [rawSegments]);
|
}, [rawSegments, providedSegments]);
|
||||||
|
|
||||||
const selectedSegment = useMemo(
|
const selectedSegment = useMemo(
|
||||||
() => segments?.find((segment) => segment.id === segmentId),
|
() => segments?.find((segment) => segment.id === segmentId),
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export const ZSegmentStorage = z.record(
|
|||||||
|
|
||||||
export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
|
export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
|
||||||
|
|
||||||
type UseSegmentsProps = {
|
export type UseSegmentsProps = {
|
||||||
tableIdentifier: string;
|
tableIdentifier: string;
|
||||||
activeFilters: ActiveFilters;
|
activeFilters: ActiveFilters;
|
||||||
sorting: SortingState;
|
sorting: SortingState;
|
||||||
@@ -288,9 +288,10 @@ type UseSegmentsProps = {
|
|||||||
setPageSize: (pageSize: number) => void;
|
setPageSize: (pageSize: number) => void;
|
||||||
setPageIndex: (pageIndex: number) => void;
|
setPageIndex: (pageIndex: number) => void;
|
||||||
setSearchTerm: (searchTerm: string | null) => void;
|
setSearchTerm: (searchTerm: string | null) => void;
|
||||||
|
segments?: FilterSegmentOutput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseSegmentsReturn = {
|
export type UseSegmentsReturn = {
|
||||||
segments: FilterSegmentOutput[];
|
segments: FilterSegmentOutput[];
|
||||||
selectedSegment: FilterSegmentOutput | undefined;
|
selectedSegment: FilterSegmentOutput | undefined;
|
||||||
canSaveSegment: boolean;
|
canSaveSegment: boolean;
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
|
|||||||
|
|
||||||
- Delegation Credential: A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB.
|
- Delegation Credential: A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB.
|
||||||
- Delegation User Credential: A Delegation User Credential is a Credential record in DB that uses DelegationCredential record to actually access the user's calendar. A Credential record with delegationCredentialId set is a Delegation User Credential.
|
- Delegation User Credential: A Delegation User Credential is a Credential record in DB that uses DelegationCredential record to actually access the user's calendar. A Credential record with delegationCredentialId set is a Delegation User Credential.
|
||||||
|
- In-DB Delegation Credential: Another name for Delegation User Credential. This is used to build the CalendarCache records.
|
||||||
|
- In-Memory Delegation Credential: It is a Credential like object but only in-memory and has id=-1. This is used to to connect with the third party Calendar. We might want to move away from In-Memory Delegation Credential to use In-DB Delegation Credential in future.
|
||||||
|
|
||||||
### How Delegation Credential works
|
### How Delegation Credential works
|
||||||
|
|
||||||
@@ -109,9 +111,9 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
|
|||||||
|
|
||||||
### Cron Jobs
|
### Cron Jobs
|
||||||
|
|
||||||
Cron jobs ensure that for each and every member of the organization that has Delegation Credential enabled, corresponding SelectedCalendar records are there. These crons currently run every 5 minutes, look at vercel.json for the up-to-date schedule.
|
Cron jobs ensure that for each and every member of the organization that has Delegation Credential enabled, corresponding SelectedCalendar records are there. These crons currently run every 5 minutes and process a batch in one run to avoid overloading the DB and third party CalendarAPIs, look at vercel.json for the up-to-date schedule.
|
||||||
|
|
||||||
- `credentials` cron job creates Delegation User Credential records for all the members of the organization who don't have Delegation User Credentials yet. It also ensures that on disabling Delegation Credential, the Delegation User Credentials are deleted which automatically deletes the SelectedCalendars through DB cascade.
|
- `credentials` cron job creates Delegation User Credential records for all the members of the organization who don't have Delegation User Credentials yet. It also ensures that on disabling Delegation Credential, the Delegation User Credentials are deleted which automatically deletes the SelectedCalendar and CalendarCache records through DB cascade.
|
||||||
- `selected-calendars` cron job creates SelectedCalendar records for all the Delegation User Credentials of the organization who don't have Selected Calendars yet.
|
- `selected-calendars` cron job creates SelectedCalendar records for all the Delegation User Credentials of the organization who don't have Selected Calendars yet.
|
||||||
|
|
||||||
### Important Points
|
### Important Points
|
||||||
@@ -132,11 +134,13 @@ Cron jobs ensure that for each and every member of the organization that has Del
|
|||||||
|
|
||||||
### Impact of disabling Delegation Credential
|
### Impact of disabling Delegation Credential
|
||||||
|
|
||||||
Disabling effectively stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their calendar connections broken.
|
- It immediately stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their calendar connections broken.
|
||||||
|
- Credentials cron job would delete the Delegation User Credentials which will then cascade to delete the SelectedCalendar and CalendarCache records.
|
||||||
|
|
||||||
### Impact of enabling Delegation Credential
|
### Impact of enabling Delegation Credential
|
||||||
- Existing calendar-cache records are re-used as we identify the relevant record by userId and key of CalendarCache record.
|
- Existing calendar-cache records are re-used as we identify the relevant record by userId and key of CalendarCache record.
|
||||||
- Any updates to those calendar-cache records keep on working by using the non-delegation credential attached with the SelectedCalendar record.
|
- Any updates to those calendar-cache records keep on working by using the non-delegation credential attached with the SelectedCalendar record.
|
||||||
|
- In case there is an error while watching the SelectedCalendar using non-delegation credential, we will delete the SelectedCalendar record and create a new one using Delegation User Credential.
|
||||||
- For any new members, we create Credential records and SelectedCalendar records through cron jobs and thus their calendar-cache records will also be created.
|
- For any new members, we create Credential records and SelectedCalendar records through cron jobs and thus their calendar-cache records will also be created.
|
||||||
|
|
||||||
### Notes when testing locally
|
### Notes when testing locally
|
||||||
@@ -146,8 +150,3 @@ Disabling effectively stops generating in-memory delegation user credentials. So
|
|||||||
- Make sure to change the email of the user above to your workspace owner's email(other member's email might also work). This is necessary otherwise you won't be able to enable Delegation Credential for the organization.
|
- Make sure to change the email of the user above to your workspace owner's email(other member's email might also work). This is necessary otherwise you won't be able to enable Delegation Credential for the organization.
|
||||||
- Note: After changing the email, you would have to logout and login again
|
- Note: After changing the email, you would have to logout and login again
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
- Test what happens when credential expires that was used in CalendarCache/SelectedCalendar
|
|
||||||
- It seems that if refresh token is valid then it would still be refreshed but if it becomes invalid then it ends up causing the calendar-cache updates to break because it isn't able to renew the access token. How do you fix it?
|
|
||||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user