Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84d80eea6 | ||
|
|
f3e1d67de1 | ||
|
|
2ce25f54d4 | ||
|
|
25eac5d29b | ||
|
|
1710e30719 | ||
|
|
6d970d0b57 | ||
|
|
41a6035a12 | ||
|
|
f84c8239e7 | ||
|
|
1dca160db0 | ||
|
|
4ff366cd99 | ||
|
|
21a4711f07 | ||
|
|
f1dd217de7 | ||
|
|
3e8f0b125d | ||
|
|
25246ef8ff | ||
|
|
bb98143e2e | ||
|
|
6258ca14d0 | ||
|
|
d0e17e8611 | ||
|
|
79dc76239b | ||
|
|
95d62354d9 | ||
|
|
b9fadf82a7 | ||
|
|
1740e34c79 | ||
|
|
9e09066e99 | ||
|
|
0a9cfd3a08 | ||
|
|
0be711ad6e | ||
|
|
1c507b8169 | ||
|
|
81bdb3dc91 | ||
|
|
5efc7f3856 | ||
|
|
0afecec607 | ||
|
|
920574de65 |
@@ -188,6 +188,10 @@ TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
|
||||
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
|
||||
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
|
||||
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
|
||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
+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.
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
@@ -21,16 +23,53 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getCachedMe = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
|
||||
return await meCaller.get();
|
||||
},
|
||||
["viewer.me.get"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const getCachedAvailabilities = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const availabilityCaller = await createRouterCaller(
|
||||
availabilityRouter,
|
||||
await getTRPCContext(headers, cookies)
|
||||
);
|
||||
return await availabilityCaller.list();
|
||||
},
|
||||
["viewer.availability.list"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const t = await getTranslate();
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
|
||||
const [meCaller, availabilityCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(availabilityRouter),
|
||||
const [me, cachedAvailabilities] = await Promise.all([
|
||||
getCachedMe(_headers, _cookies),
|
||||
getCachedAvailabilities(_headers, _cookies),
|
||||
]);
|
||||
|
||||
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
|
||||
// Transform the data to ensure startTime, endTime, and date are Date objects
|
||||
// This is because the data is cached and as a result the data is converted to a string
|
||||
const availabilities = {
|
||||
...cachedAvailabilities,
|
||||
schedules: cachedAvailabilities.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
availability: schedule.availability.map((avail) => ({
|
||||
...avail,
|
||||
startTime: new Date(avail.startTime),
|
||||
endTime: new Date(avail.endTime),
|
||||
date: avail.date ? new Date(avail.date) : null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
|
||||
const isOrgPrivate = organizationId
|
||||
? await OrganizationRepository.checkIfPrivate({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -23,9 +24,40 @@ export const generateMetadata = async () =>
|
||||
"/event-types"
|
||||
);
|
||||
|
||||
const getCachedMe = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
|
||||
return await meCaller.get();
|
||||
},
|
||||
["viewer.me.get"],
|
||||
{ revalidate: 3600 } // seconds
|
||||
);
|
||||
|
||||
const getCachedEventGroups = unstable_cache(
|
||||
async (
|
||||
headers: ReadonlyHeaders,
|
||||
cookies: ReadonlyRequestCookies,
|
||||
filters?: {
|
||||
teamIds?: number[] | undefined;
|
||||
userIds?: number[] | undefined;
|
||||
upIds?: string[] | undefined;
|
||||
}
|
||||
) => {
|
||||
const eventTypesCaller = await createRouterCaller(
|
||||
eventTypesRouter,
|
||||
await getTRPCContext(headers, cookies)
|
||||
);
|
||||
return await eventTypesCaller.getUserEventGroups({ filters });
|
||||
},
|
||||
["viewer.eventTypes.getUserEventGroups"],
|
||||
{ revalidate: 3600 } // seconds
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
const _searchParams = await searchParams;
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, _searchParams);
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
const context = buildLegacyCtx(_headers, _cookies, await params, _searchParams);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -34,15 +66,9 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
|
||||
const [meCaller, eventTypesCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(eventTypesRouter),
|
||||
]);
|
||||
|
||||
const [me, userEventGroupsData] = await Promise.all([
|
||||
meCaller.get(),
|
||||
eventTypesCaller.getUserEventGroups({ filters }),
|
||||
getCachedMe(_headers, _cookies),
|
||||
getCachedEventGroups(_headers, _cookies, filters),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -56,4 +82,3 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export const revalidate = 3600; // 1 hour in seconds
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
@@ -30,6 +31,15 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getCachedEventType = unstable_cache(
|
||||
async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies));
|
||||
return await caller.get({ id: eventTypeId });
|
||||
},
|
||||
["viewer.eventTypes.get"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const ServerPage = async ({ params }: PageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -41,8 +51,10 @@ const ServerPage = async ({ params }: PageProps) => {
|
||||
throw new Error("Invalid Event Type id");
|
||||
}
|
||||
const eventTypeId = parsed.data.type;
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const data = await caller.get({ id: eventTypeId });
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
|
||||
const data = await getCachedEventType(eventTypeId, _headers, _cookies);
|
||||
if (!data?.eventType) {
|
||||
throw new Error("This event type does not exist");
|
||||
}
|
||||
|
||||
+25
-10
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
@@ -27,6 +29,8 @@ import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { VerticalTabItem } from "@calcom/ui/components/navigation";
|
||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type TeamFeatures = Record<number, Record<keyof AppFlags, boolean>> | null;
|
||||
|
||||
const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
const tabs: VerticalTabItemProps[] = [
|
||||
{
|
||||
@@ -272,6 +276,7 @@ interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
teamFeatures?: TeamFeatures;
|
||||
}
|
||||
|
||||
const TeamListCollapsible = () => {
|
||||
@@ -438,6 +443,7 @@ const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
teamFeatures,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
@@ -449,11 +455,15 @@ const SettingsSidebarContainer = ({
|
||||
}[]
|
||||
>();
|
||||
const session = useSession();
|
||||
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
|
||||
const isDelegationCredentialEnabled = useIsFeatureEnabledForTeam({
|
||||
teamFeatures,
|
||||
teamId: session.data?.user?.org?.id,
|
||||
feature: "delegation-credential",
|
||||
});
|
||||
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
||||
isDelegationCredentialEnabled,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
@@ -727,9 +737,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
teamFeatures?: TeamFeatures;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
||||
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
@@ -761,6 +772,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
||||
<SidebarContainerElement
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
teamFeatures={teamFeatures}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
@@ -778,10 +790,18 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
teamFeatures?: TeamFeatures;
|
||||
};
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
teamFeatures,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -797,13 +817,8 @@ const SidebarContainerElement = ({
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
teamFeatures={teamFeatures}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -16,9 +18,22 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
let teamFeatures: Record<number, Record<keyof AppFlags, boolean>> | null = null;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (session?.user.org) {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const features = await featuresRepository.getTeamFeatures(session.user.org.id);
|
||||
if (features) {
|
||||
teamFeatures = {
|
||||
[session.user.org.id]: features,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} />
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("organization_members")}
|
||||
subtitle={t("organization_description")}
|
||||
headerClassName="hidden md:block"
|
||||
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import MembersPage from "~/members/members-view";
|
||||
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
@@ -11,8 +15,20 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/organizations/${(await params).id}/members`
|
||||
);
|
||||
|
||||
const ServerPageWrapper = () => {
|
||||
return <MembersPage />;
|
||||
const ServerPageWrapper = async () => {
|
||||
const [orgCaller, attributesCaller] = await Promise.all([
|
||||
createRouterCaller(viewerOrganizationsRouter),
|
||||
createRouterCaller(attributesRouter),
|
||||
]);
|
||||
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
attributesCaller.list(),
|
||||
]);
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import SettingsLayoutAppDirClient from "app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient";
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
|
||||
<SettingsHeader
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
|
||||
{children}
|
||||
</SettingsHeader>
|
||||
</SettingsLayoutAppDirClient>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import SettingsLayoutAppDir from "app/(use-page-wrapper)/settings/(settings-layout)/layout";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/members";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -15,17 +16,20 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const [orgCaller, attributesCaller] = await Promise.all([
|
||||
createRouterCaller(viewerOrganizationsRouter),
|
||||
createRouterCaller(attributesRouter),
|
||||
]);
|
||||
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
attributesCaller.list(),
|
||||
]);
|
||||
|
||||
const children = (
|
||||
<SettingsHeader
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
);
|
||||
return await SettingsLayoutAppDir({ children, containerClassName: "lg:max-w-screen-2xl" });
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={
|
||||
<div className="flex">
|
||||
<h1>Member management</h1>
|
||||
<Button
|
||||
tooltip="Only teammates invited as admins can create OAuth clients while teammates invited as members have read only access"
|
||||
tooltipSide="right"
|
||||
className="mx-2 hover:bg-transparent"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="info"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
title={t("platform_members")}
|
||||
subtitle={t("platform_members_description")}
|
||||
withoutMain={false}
|
||||
isPlatformUser={true}
|
||||
actions={<div className={CTA_CONTAINER_CLASS_NAME} />}>
|
||||
{children}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
|
||||
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -11,8 +14,26 @@ export const generateMetadata = async () =>
|
||||
"/settings/platform/members"
|
||||
);
|
||||
|
||||
const ServerPageWrapper = () => {
|
||||
return <PlatformMembersView />;
|
||||
const ServerPageWrapper = async () => {
|
||||
const [orgCaller, attributesCaller] = await Promise.all([
|
||||
createRouterCaller(viewerOrganizationsRouter),
|
||||
createRouterCaller(attributesRouter),
|
||||
]);
|
||||
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
attributesCaller.list(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PlatformMembersView
|
||||
org={org}
|
||||
teams={teams}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
attributes={attributes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPageWrapper;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { TRPCContext } from "@calcom/trpc/types/server/createContext";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { AnyRouter } from "@trpc/server";
|
||||
|
||||
const getTRPCContext = async () => {
|
||||
const legacyReq = buildLegacyRequest(await headers(), await cookies());
|
||||
export const getTRPCContext = async (_headers?: ReadonlyHeaders, _cookies?: ReadonlyRequestCookies) => {
|
||||
const legacyReq = buildLegacyRequest(_headers ?? (await headers()), _cookies ?? (await cookies()));
|
||||
return await createContext({ req: legacyReq, res: {} as any }, getServerSession);
|
||||
};
|
||||
|
||||
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter) {
|
||||
const trpcContext = await getTRPCContext();
|
||||
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter, context?: TRPCContext) {
|
||||
const trpcContext = context ? context : await getTRPCContext();
|
||||
const createCaller = createCallerFactory<TRouter>(router);
|
||||
return createCaller(trpcContext);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,6 @@ export type PageProps = {
|
||||
};
|
||||
|
||||
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
|
||||
|
||||
export { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
export { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { handleCreateCredentials } from "../route";
|
||||
|
||||
type OrgParams = { id: number };
|
||||
const createOrg = async ({ id }: OrgParams) =>
|
||||
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
|
||||
|
||||
type UserParams = { id: number; email: string };
|
||||
const createUser = async ({ id, email }: UserParams) => await prismock.user.create({ data: { id, email } });
|
||||
|
||||
type WorkspacePlatformParams = { id: number; name?: string; slug?: string; description?: string };
|
||||
const createWorkspacePlatform = async ({ id, name, slug, description }: WorkspacePlatformParams) =>
|
||||
await prismock.workspacePlatform.create({
|
||||
data: {
|
||||
id,
|
||||
name: name ?? "Google",
|
||||
slug: slug ?? "google",
|
||||
description: description ?? "Test platform",
|
||||
defaultServiceAccountKey: {},
|
||||
},
|
||||
});
|
||||
|
||||
type DelegationCredentialParams = {
|
||||
id: string;
|
||||
orgId: number;
|
||||
domain: string;
|
||||
workspacePlatformId: number;
|
||||
};
|
||||
|
||||
const createDelegationCredential = async ({
|
||||
id,
|
||||
orgId,
|
||||
domain,
|
||||
workspacePlatformId,
|
||||
}: DelegationCredentialParams) =>
|
||||
await prismock.delegationCredential.create({
|
||||
data: {
|
||||
id,
|
||||
enabled: true,
|
||||
domain,
|
||||
organizationId: orgId,
|
||||
workspacePlatformId,
|
||||
serviceAccountKey: {
|
||||
client_email: "svc@example.com",
|
||||
private_key: "pk",
|
||||
client_id: "cid",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CredentialParams = { id?: number; userId?: number; delegationCredentialId?: string; key?: object };
|
||||
const createCredential = async ({
|
||||
id = 1,
|
||||
userId = 1,
|
||||
delegationCredentialId = "delegation-credential-1",
|
||||
key = {},
|
||||
}: CredentialParams = {}) =>
|
||||
await prismock.credential.create({
|
||||
data: {
|
||||
id,
|
||||
type: "google_calendar",
|
||||
key,
|
||||
userId,
|
||||
appId: "google_calendar",
|
||||
delegationCredentialId,
|
||||
},
|
||||
});
|
||||
|
||||
type MembershipParams = { teamId: number; userId: number; role?: MembershipRole };
|
||||
const createMembership = async ({ teamId, userId, role = "MEMBER" }: MembershipParams) =>
|
||||
await prismock.membership.create({ data: { teamId, userId, accepted: true, role } });
|
||||
|
||||
// Helper to assert credentials in DB match expected (partial, order-insensitive)
|
||||
type PartialCredential = Partial<Awaited<ReturnType<typeof prismock.credential.create>>>;
|
||||
async function expectCredentials(expected: PartialCredential[]) {
|
||||
const actual = await prismock.credential.findMany({});
|
||||
expect(actual.length).toBe(expected.length);
|
||||
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
|
||||
}
|
||||
|
||||
describe("Delegation Credentials: Organization-wide Google Calendar Access", () => {
|
||||
beforeEach(() => {
|
||||
prismock.delegationCredential.deleteMany();
|
||||
prismock.credential.deleteMany();
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
prismock.membership.deleteMany();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are enabled", async () => {
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result).toEqual({
|
||||
message: "No enabled delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
});
|
||||
await expectCredentials([]);
|
||||
});
|
||||
|
||||
it("creates credentials for all eligible organization members when Delegation Credential is enabled", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
|
||||
const user1 = await createUser({ id: 1, email: "alice@example.com" });
|
||||
const user2 = await createUser({ id: 2, email: "bob@example.com" });
|
||||
await createMembership({ teamId: org.id, userId: user1.id });
|
||||
await createMembership({ teamId: org.id, userId: user2.id });
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: org.id,
|
||||
domain: "example.com",
|
||||
workspacePlatformId: workspacePlatform.id,
|
||||
});
|
||||
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(2);
|
||||
expect(result.failures).toBe(0);
|
||||
await expectCredentials([
|
||||
{ userId: user1.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||
{ userId: user2.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips credential creation for unsupported workspace platforms", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
// Create a non-google workspace platform
|
||||
const outlookPlatform = await createWorkspacePlatform({
|
||||
id: 2,
|
||||
name: "Outlook",
|
||||
slug: "outlook",
|
||||
description: "Test",
|
||||
});
|
||||
const user = await createUser({ id: 1, email: "user1@outlook.com" });
|
||||
await createMembership({ teamId: org.id, userId: user.id });
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-2",
|
||||
orgId: org.id,
|
||||
domain: "outlook.com",
|
||||
workspacePlatformId: outlookPlatform.id,
|
||||
});
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(0);
|
||||
await expectCredentials([]);
|
||||
});
|
||||
|
||||
it("does not create duplicate credentials for users who already have one", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
await createMembership({ teamId: org.id, userId: user.id });
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: org.id,
|
||||
domain: "example.com",
|
||||
workspacePlatformId: workspacePlatform.id,
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: "delegation-credential-1" });
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(0);
|
||||
await expectCredentials([
|
||||
{ userId: user.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("processes only a batch of members at a time if there are more than the batch size", async () => {
|
||||
const org = await createOrg({ id: 1 });
|
||||
const workspacePlatform = await createWorkspacePlatform({
|
||||
id: 1,
|
||||
name: "Google",
|
||||
slug: "google",
|
||||
description: "Test platform",
|
||||
});
|
||||
// Simulate more than 100 delegated members
|
||||
const users = [];
|
||||
for (let i = 1; i <= 120; i++) {
|
||||
const user = await createUser({ id: i, email: `user${i}@example.com` });
|
||||
users.push(user);
|
||||
await createMembership({ teamId: org.id, userId: user.id });
|
||||
}
|
||||
await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: org.id,
|
||||
domain: "example.com",
|
||||
workspacePlatformId: workspacePlatform.id,
|
||||
});
|
||||
const result = await handleCreateCredentials();
|
||||
expect(result.success).toBe(100); // Only batch size processed
|
||||
await expectCredentials(
|
||||
users.slice(0, 100).map((u) => ({
|
||||
userId: u.id,
|
||||
delegationCredentialId: "delegation-credential-1",
|
||||
type: "google_calendar",
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { DelegationCredentialRepository } from "@calcom/lib/server/repository/de
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["CreateCredentials"] });
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[credentials/cron]"] });
|
||||
const batchSizeToCreateCredentials = 100;
|
||||
const validateRequest = (req: NextRequest) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -24,9 +24,12 @@ const validateRequest = (req: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateCredentials() {
|
||||
export async function handleCreateCredentials() {
|
||||
const log = moduleLogger.getSubLogger({ prefix: ["CreateCredentials"] });
|
||||
const delegationCredentials = await DelegationCredentialRepository.findAllEnabledIncludeDelegatedMembers();
|
||||
|
||||
log.info(`Found ${delegationCredentials.length} enabled delegation credentials to create credentials for`);
|
||||
|
||||
if (!delegationCredentials.length) {
|
||||
return {
|
||||
message: "No enabled delegation credentials found",
|
||||
@@ -107,9 +110,12 @@ async function handleCreateCredentials() {
|
||||
}
|
||||
|
||||
async function handleDeleteCredentials() {
|
||||
const log = moduleLogger.getSubLogger({ prefix: ["DeleteCredentials"] });
|
||||
const delegationCredentials =
|
||||
await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess();
|
||||
|
||||
log.info(`Found ${delegationCredentials.length} delegation credentials to delete credentials for`);
|
||||
|
||||
if (!delegationCredentials.length) {
|
||||
return {
|
||||
message: "No disabled delegation credentials found",
|
||||
|
||||
@@ -95,7 +95,7 @@ async function postHandler(request: NextRequest) {
|
||||
const userIdsFromTeams = team.members.map((u) => u.userId);
|
||||
|
||||
// Booking Events
|
||||
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
|
||||
const whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
@@ -122,7 +122,7 @@ async function postHandler(request: NextRequest) {
|
||||
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
|
||||
|
||||
// Most Booked Event Type
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
const bookingWhere: Prisma.BookingTimeStatusDenormalizedWhereInput = {
|
||||
createdAt: {
|
||||
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
|
||||
lte: dayjs(new Date()).endOf("day").toDate(),
|
||||
@@ -141,7 +141,7 @@ async function postHandler(request: NextRequest) {
|
||||
],
|
||||
};
|
||||
|
||||
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
|
||||
const bookingsFromSelected = await prisma.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["eventTypeId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -234,7 +234,7 @@ async function postHandler(request: NextRequest) {
|
||||
});
|
||||
|
||||
// Most booked members
|
||||
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await prisma.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
import "@calcom/lib/server/__mocks__/serviceAccountKey";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
|
||||
|
||||
import { handleCreateSelectedCalendars } from "../route";
|
||||
|
||||
// Mock GoogleCalendarService
|
||||
const fetchPrimaryCalendarMock = vi.fn();
|
||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
type OrgParams = { id?: number };
|
||||
const createOrg = async ({ id = 1 }: OrgParams = {}) =>
|
||||
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
|
||||
|
||||
type UserParams = { id?: number; email?: string };
|
||||
const createUser = async ({ id = 1, email = `user${id}@example.com` }: UserParams = {}) =>
|
||||
await prismock.user.create({ data: { id, email } });
|
||||
|
||||
type WorkspacePlatformParams = { id?: number };
|
||||
const createWorkspacePlatform = async ({ id = 1 }: WorkspacePlatformParams = {}) =>
|
||||
await prismock.workspacePlatform.create({
|
||||
data: {
|
||||
id,
|
||||
name: "Google",
|
||||
slug: "google",
|
||||
description: "Test platform",
|
||||
defaultServiceAccountKey: {},
|
||||
},
|
||||
});
|
||||
|
||||
type DelegationCredentialParams = { id: string; orgId: number; domain: string };
|
||||
const createDelegationCredential = async ({ id, orgId, domain }: DelegationCredentialParams) =>
|
||||
await prismock.delegationCredential.create({
|
||||
data: {
|
||||
id,
|
||||
enabled: true,
|
||||
domain: domain ?? "example.com",
|
||||
organizationId: orgId ?? 1,
|
||||
workspacePlatformId: 1,
|
||||
serviceAccountKey: {
|
||||
client_email: "svc@example.com",
|
||||
private_key: "pk",
|
||||
client_id: "cid",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CredentialParams = {
|
||||
id: number;
|
||||
userId: number;
|
||||
delegationCredentialId: string | null;
|
||||
key?: object;
|
||||
};
|
||||
const createCredential = async ({ id, userId, delegationCredentialId, key = {} }: CredentialParams) =>
|
||||
await prismock.credential.create({
|
||||
data: {
|
||||
id,
|
||||
type: "google_calendar",
|
||||
key,
|
||||
userId,
|
||||
appId: "google_calendar",
|
||||
delegationCredentialId,
|
||||
},
|
||||
});
|
||||
|
||||
type SelectedCalendarParams = {
|
||||
userId?: number;
|
||||
externalId?: string;
|
||||
error?: string | null;
|
||||
id?: string;
|
||||
delegationCredentialId?: string | null;
|
||||
credentialId?: number;
|
||||
};
|
||||
const createSelectedCalendar = async ({
|
||||
userId = 1,
|
||||
externalId = "user1@example.com",
|
||||
error = null,
|
||||
id = `${userId}-sc`,
|
||||
delegationCredentialId = null,
|
||||
credentialId = 1,
|
||||
}: SelectedCalendarParams = {}) =>
|
||||
await prismock.selectedCalendar.create({
|
||||
data: {
|
||||
id,
|
||||
integration: "google_calendar",
|
||||
externalId,
|
||||
userId,
|
||||
delegationCredentialId,
|
||||
credentialId,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to assert selected calendars in DB match expected (partial, order-insensitive)
|
||||
type PartialSelectedCalendar = Partial<Awaited<ReturnType<typeof prismock.selectedCalendar.create>>>;
|
||||
async function expectSelectedCalendars(expected: PartialSelectedCalendar[]) {
|
||||
const actual = await prismock.selectedCalendar.findMany({});
|
||||
expect(actual.length).toBe(expected.length);
|
||||
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
|
||||
}
|
||||
|
||||
describe("handleCreateSelectedCalendars integration", () => {
|
||||
beforeEach(() => {
|
||||
prismock.delegationCredential.deleteMany();
|
||||
prismock.credential.deleteMany();
|
||||
prismock.selectedCalendar.deleteMany();
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
fetchPrimaryCalendarMock.mockReset();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result).toEqual({
|
||||
message: "No delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("automatically creates a Selected Calendar for every eligible user when Delegation Credential is enabled and user matches domain", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
expect(result.failures).toBe(0);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a Selected Calendar even if the user's primary calendar ID does not match their email address", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "notuser@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not create duplicate Selected Calendars for users who already have a valid one", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
await createSelectedCalendar({
|
||||
userId: user.id,
|
||||
externalId: "user1@example.com",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
credentialId: 1,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces a Selected Calendar if the existing one has an error, ensuring users always have a working calendar connection", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
// Create a Delegation User Credential
|
||||
await createCredential({
|
||||
id: 2,
|
||||
userId: user.id,
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
// Create a Regular User Credential
|
||||
const regularCredential = await createCredential({
|
||||
id: 1,
|
||||
userId: user.id,
|
||||
delegationCredentialId: null,
|
||||
});
|
||||
// Create a SelectedCalendar attached to regular credential
|
||||
await createSelectedCalendar({
|
||||
userId: user.id,
|
||||
externalId: "user1@example.com",
|
||||
error: "some error" as unknown as null,
|
||||
credentialId: regularCredential.id,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
await expectSelectedCalendars([
|
||||
{
|
||||
userId: user.id,
|
||||
externalId: "user1@example.com",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("when GoogleCalendarService throws error", () => {
|
||||
it("creates SelectedCalendar with user.email when CalendarAppDelegationCredentialInvalidGrantError is thrown", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||
);
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
await expectSelectedCalendars([
|
||||
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not create SelectedCalendar when some other error than CalendarAppDelegationCredentialInvalidGrantError is thrown", async () => {
|
||||
await createOrg({ id: 1 });
|
||||
await createWorkspacePlatform({ id: 1 });
|
||||
const user = await createUser({ id: 1, email: "user1@example.com" });
|
||||
const delegationCredential = await createDelegationCredential({
|
||||
id: "delegation-credential-1",
|
||||
orgId: 1,
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
await expectSelectedCalendars([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
|
||||
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -19,7 +20,7 @@ import type { Ensure } from "@calcom/types/utils";
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const limitOnQueryingGoogleCalendar = 50;
|
||||
const log = logger.getSubLogger({ prefix: ["[api] selected-calendars/cron"] });
|
||||
const log = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[selected-calendars/cron]"] });
|
||||
const validateRequest = (req: NextRequest) => {
|
||||
const url = new URL(req.url);
|
||||
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
|
||||
@@ -28,13 +29,191 @@ const validateRequest = (req: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateSelectedCalendars() {
|
||||
type DelegationUserCredential = Awaited<
|
||||
ReturnType<typeof CredentialRepository.findAllDelegationByTypeIncludeUserAndTake>
|
||||
>[number];
|
||||
|
||||
type DelegationUserCredentialWithEnsuredUser = Ensure<DelegationUserCredential, "user">;
|
||||
|
||||
async function getDelegationUserCredentialsToProcess(delegationUserCredentials: DelegationUserCredential[]) {
|
||||
const delegationUserCredentialsToProcess = await Promise.all(
|
||||
delegationUserCredentials
|
||||
.filter(
|
||||
(delegationUserCredential): delegationUserCredential is DelegationUserCredentialWithEnsuredUser =>
|
||||
delegationUserCredential.user !== null
|
||||
)
|
||||
.map(async (delegationUserCredential) => {
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
// Consider existing SelectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
|
||||
// It is also important because SelectedCalendar has composite unique constraint on userId, externalId and integration and thus we couldn't have the same values for different credentialId
|
||||
const selectedCalendar = await SelectedCalendarRepository.findFirst({
|
||||
where: {
|
||||
userId: delegationUserCredential.user.id,
|
||||
externalId: userEmail,
|
||||
integration: "google_calendar",
|
||||
},
|
||||
});
|
||||
|
||||
if (selectedCalendar) {
|
||||
if (selectedCalendar.delegationCredentialId === delegationUserCredential.delegationCredentialId) {
|
||||
// In case this SelectedCalendar has an error, we would let CalendarCache cron handle it
|
||||
log.info(
|
||||
`Found an existing SelectedCalendar for userId: ${delegationUserCredential.user.id} and delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!selectedCalendar.error) {
|
||||
log.info(`Found a reusable SelectedCalendar for userId: ${delegationUserCredential.user.id}`);
|
||||
return null;
|
||||
} else {
|
||||
log.error(
|
||||
`Found reusable Non-Delegation SelectedCalendar for ${delegationUserCredential.user.id} but it has an error: '${selectedCalendar.error}', so deleting it, new one will be created`
|
||||
);
|
||||
|
||||
// We found a SelectedCalendar attached to non-delegation credential, so we shouldn't reuse it as the non-delegation credential attached to it could be invalid
|
||||
// So, we delete it so that SelectedCalendarRepository allow creating a similar one with Delegation Credential
|
||||
// Also, we don't delete the possibly invalid credential due to false alarm and we would need that intact in case we disable Delegation Credential
|
||||
// Because credential isn't deleted, the corresponding CalendarCache entry would still exist and thus we will end up having two CalendarCache entries for same key and userId with different credentialId
|
||||
await SelectedCalendarRepository.deleteById({ id: selectedCalendar.id });
|
||||
return delegationUserCredential;
|
||||
}
|
||||
}
|
||||
return delegationUserCredential;
|
||||
})
|
||||
);
|
||||
|
||||
return delegationUserCredentialsToProcess.filter(
|
||||
(credential): credential is DelegationUserCredentialWithEnsuredUser => credential !== null
|
||||
);
|
||||
}
|
||||
|
||||
async function getCalendarService(delegationUserCredential: DelegationUserCredentialWithEnsuredUser) {
|
||||
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
});
|
||||
|
||||
if (!credentialForCalendarService) {
|
||||
log.error(
|
||||
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!credentialForCalendarService.delegatedTo ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
|
||||
) {
|
||||
log.error(
|
||||
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
|
||||
safeStringify({
|
||||
delegatedToSet: !!credentialForCalendarService.delegatedTo,
|
||||
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
|
||||
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const googleCalendarService = new GoogleCalendarService(
|
||||
credentialForCalendarService as CredentialForCalendarServiceWithEmail
|
||||
);
|
||||
|
||||
return googleCalendarService;
|
||||
}
|
||||
|
||||
async function fetchPrimaryCalendarId({
|
||||
googleCalendarService,
|
||||
delegationUserCredential,
|
||||
}: {
|
||||
googleCalendarService: GoogleCalendarService;
|
||||
delegationUserCredential: DelegationUserCredentialWithEnsuredUser;
|
||||
}) {
|
||||
let primaryCalendarId;
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
|
||||
try {
|
||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
primaryCalendarId = primaryCalendar?.id;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify(error)
|
||||
);
|
||||
|
||||
if (error instanceof CalendarAppDelegationCredentialInvalidGrantError) {
|
||||
// It is known to happen in case john+test@acme.com is the user email and the actual email is john@acme.com. Google rejects the authorization for john+test@acme.com
|
||||
log.info(
|
||||
`Error seems to be that the email isn't present in the third party workspace, further attempts would fail too, so we assume the user's email as the primary calendar id to skip this user in future cron runs`
|
||||
);
|
||||
// If we are unable to fetch the primary calendar, we assume the user's email as the primary calendar id
|
||||
// It ensures that this calendar is skipped in the future cron runs
|
||||
primaryCalendarId = userEmail;
|
||||
}
|
||||
}
|
||||
|
||||
return primaryCalendarId;
|
||||
}
|
||||
|
||||
async function processDelegationUserCredential(
|
||||
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
|
||||
) {
|
||||
try {
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
const googleCalendarService = await getCalendarService(delegationUserCredential);
|
||||
if (!googleCalendarService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const externalCalendarId = await fetchPrimaryCalendarId({
|
||||
googleCalendarService,
|
||||
delegationUserCredential,
|
||||
});
|
||||
|
||||
if (!externalCalendarId) {
|
||||
log.error(
|
||||
`External calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`
|
||||
);
|
||||
throw new Error("externalCalendarId could not be determined");
|
||||
}
|
||||
|
||||
if (externalCalendarId !== userEmail) {
|
||||
// We don't know the scenario when Delegation Credential allows access to an email that is not the primary calendar email
|
||||
// So log a warning for further investigation
|
||||
log.warn(
|
||||
`External calendar id mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify({
|
||||
externalCalendarId,
|
||||
userEmail,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await SelectedCalendarRepository.create({
|
||||
integration: "google_calendar",
|
||||
externalId: externalCalendarId,
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
credentialId: delegationUserCredential.id,
|
||||
});
|
||||
|
||||
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateSelectedCalendars() {
|
||||
// These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere
|
||||
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
|
||||
type: "google_calendar",
|
||||
take: limitOnQueryingGoogleCalendar,
|
||||
});
|
||||
|
||||
log.info(`Found ${allDelegationUserCredentials.length} delegation user credentials to process`);
|
||||
|
||||
if (!allDelegationUserCredentials.length) {
|
||||
const message = "No delegation credentials found";
|
||||
log.info(message);
|
||||
@@ -57,106 +236,16 @@ async function handleCreateSelectedCalendars() {
|
||||
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
|
||||
groupedDelegationUserCredentials
|
||||
)) {
|
||||
// First, create an array of promises that check if each credential should be processed
|
||||
const checkedDelegationUserCredentials = await Promise.all(
|
||||
delegationUserCredentials.map(async (delegationUserCredential) => {
|
||||
if (!delegationUserCredential.user) {
|
||||
log.error(`Credential ${delegationUserCredential.id} has no user`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all selectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
|
||||
const hasSelectedCalendarForUserEmail = !!(await SelectedCalendarRepository.findFirst({
|
||||
where: {
|
||||
userId: delegationUserCredential.user.id,
|
||||
externalId: delegationUserCredential.user.email,
|
||||
},
|
||||
}));
|
||||
|
||||
if (hasSelectedCalendarForUserEmail) {
|
||||
log.info(`Found a reusable SelectedCalendar for ${delegationUserCredential.user.id}`);
|
||||
return null;
|
||||
}
|
||||
return delegationUserCredential;
|
||||
})
|
||||
log.info(`Processing delegation user credentials for delegationCredentialId: ${delegationCredentialId}`);
|
||||
const delegationUserCredentialsToProcess = await getDelegationUserCredentialsToProcess(
|
||||
delegationUserCredentials
|
||||
);
|
||||
|
||||
const delegationUserCredentialsToProcess = checkedDelegationUserCredentials.filter(
|
||||
(credential): credential is Ensure<(typeof delegationUserCredentials)[number], "user"> =>
|
||||
credential !== null
|
||||
);
|
||||
|
||||
log.info(
|
||||
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
delegationUserCredentialsToProcess.map(async (delegationUserCredential) => {
|
||||
try {
|
||||
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
});
|
||||
|
||||
if (!credentialForCalendarService) {
|
||||
log.error(
|
||||
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!credentialForCalendarService.delegatedTo ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
|
||||
) {
|
||||
log.error(
|
||||
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
|
||||
safeStringify({
|
||||
delegatedToSet: !!credentialForCalendarService.delegatedTo,
|
||||
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
|
||||
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const googleCalendarService = new GoogleCalendarService(
|
||||
credentialForCalendarService as CredentialForCalendarServiceWithEmail
|
||||
);
|
||||
|
||||
let primaryCalendar;
|
||||
try {
|
||||
primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primaryCalendar || !primaryCalendar.id) {
|
||||
log.error(
|
||||
`Primary calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await SelectedCalendarRepository.create({
|
||||
integration: "google_calendar",
|
||||
externalId: primaryCalendar.id,
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
credentialId: delegationUserCredential.id,
|
||||
});
|
||||
|
||||
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
delegationUserCredentialsToProcess.map(processDelegationUserCredential)
|
||||
);
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failureCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
|
||||
@@ -87,7 +87,9 @@ function buildParsedBooking(booking: BookingItemProps) {
|
||||
>)
|
||||
: null;
|
||||
|
||||
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
|
||||
return {
|
||||
...booking,
|
||||
eventType: bookingEventType,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { EmbedProps } from "app/WithEmbedSSR";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,11 +6,11 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -112,17 +111,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const caller = await createRouterCaller(publicViewerRouter);
|
||||
|
||||
const eventData = await caller.event({
|
||||
username: name,
|
||||
eventSlug: slug,
|
||||
isTeamEvent,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
const eventData = await EventRepository.getPublicEvent(
|
||||
{
|
||||
username: name,
|
||||
eventSlug: slug,
|
||||
isTeamEvent,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
},
|
||||
session?.user?.id
|
||||
);
|
||||
|
||||
if (!eventData) {
|
||||
return notFound;
|
||||
|
||||
+19
-13
@@ -1,13 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
@@ -38,18 +38,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
if (!org) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
const session = await getServerSession({ req: context.req });
|
||||
const eventData = await EventRepository.getPublicEvent(
|
||||
{
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
},
|
||||
session?.user?.id
|
||||
);
|
||||
|
||||
const caller = await createRouterCaller(publicViewerRouter);
|
||||
|
||||
const eventData = await caller.event({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
|
||||
});
|
||||
|
||||
if (!eventData || !org) {
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
|
||||
@@ -115,6 +115,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
||||
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
||||
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
|
||||
|
||||
const { limit, offset } = useDataTable();
|
||||
|
||||
@@ -128,6 +129,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
@@ -214,6 +216,21 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
@@ -336,6 +353,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
||||
@@ -129,7 +129,9 @@ export default function Success(props: PageProps) {
|
||||
rescheduleLocation = bookingInfo.responses.location.optionValue;
|
||||
}
|
||||
|
||||
const parsedBookingMetadata = bookingMetadataSchema.parse(bookingInfo?.metadata || {});
|
||||
const parsed = bookingMetadataSchema.safeParse(bookingInfo?.metadata ?? null);
|
||||
const parsedBookingMetadata = parsed.success ? parsed.data : null;
|
||||
|
||||
const bookingWithParsedMetadata = {
|
||||
...bookingInfo,
|
||||
metadata: parsedBookingMetadata,
|
||||
|
||||
@@ -18,11 +18,13 @@ export default function Type({
|
||||
duration,
|
||||
hashedLink,
|
||||
durationConfig,
|
||||
eventData,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<BookingPageErrorBoundary>
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<Booker
|
||||
eventData={eventData}
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
bookingData={booking}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import MembersView from "@calcom/features/ee/organizations/pages/members";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const MembersPage: React.FC = () => {
|
||||
export const MembersView = (props: UserListTableProps) => {
|
||||
const { t } = useLocale();
|
||||
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
|
||||
const canLoggedInUserSeeMembers =
|
||||
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
|
||||
|
||||
return (
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
title={t("organization_members")}
|
||||
description={t("organization_description")}
|
||||
heading={t("organization_members")}
|
||||
subtitle={t("organization_description")}
|
||||
headerClassName="hidden md:block"
|
||||
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
<MembersView />
|
||||
</Shell>
|
||||
<LicenseRequired>
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
|
||||
{!canLoggedInUserSeeMembers && (
|
||||
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
|
||||
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
|
||||
</div>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersPage;
|
||||
|
||||
+12
-1
@@ -12,13 +12,24 @@ import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary
|
||||
|
||||
export type Props = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
function Type({ slug, user, booking, isEmbed, isBrandingHidden, entity, eventTypeId, duration }: Props) {
|
||||
function Type({
|
||||
slug,
|
||||
user,
|
||||
booking,
|
||||
isEmbed,
|
||||
isBrandingHidden,
|
||||
entity,
|
||||
eventTypeId,
|
||||
duration,
|
||||
eventData,
|
||||
}: Props) {
|
||||
return (
|
||||
<BookingPageErrorBoundary>
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
<Booker
|
||||
username={user}
|
||||
eventSlug={slug}
|
||||
eventData={eventData}
|
||||
bookingData={booking}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.3.2",
|
||||
"version": "5.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { permissionsRouter } from "@calcom/trpc/server/routers/viewer/pbac/_router";
|
||||
|
||||
export default createNextApiHandler(permissionsRouter);
|
||||
@@ -9,18 +9,65 @@ export async function selectFilter(page: Page, columnId: string) {
|
||||
await page.getByTestId(`add-filter-item-${columnId}`).click();
|
||||
}
|
||||
|
||||
export async function openFilter(page: Page, columnId: string) {
|
||||
await page.getByTestId(`filter-popover-trigger-${columnId}`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a filter with a specific value
|
||||
*/
|
||||
export async function applyFilter(page: Page, columnId: string, value: string) {
|
||||
export async function applySelectFilter(page: Page, columnId: string, value: string) {
|
||||
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||
if (!(await existingFilter.isVisible())) {
|
||||
await selectFilter(page, columnId);
|
||||
}
|
||||
await openFilter(page, columnId);
|
||||
await selectOptionValue(page, columnId, value);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await page.getByTestId(`filter-popover-trigger-${columnId}`).click();
|
||||
|
||||
export async function selectOptionValue(page: Page, columnId: string, value: string) {
|
||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
|
||||
}
|
||||
|
||||
export async function applyTextFilter(page: Page, columnId: string, operator: string, operand?: string) {
|
||||
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||
if (!(await existingFilter.isVisible())) {
|
||||
await selectFilter(page, columnId);
|
||||
}
|
||||
await openFilter(page, columnId);
|
||||
|
||||
await page.getByTestId(`text-filter-options-select-${columnId}`).click();
|
||||
await page
|
||||
.locator(`[data-testid="text-filter-options-${columnId}"] [id^="react-select-"]`)
|
||||
.getByText(operator)
|
||||
.first()
|
||||
.click();
|
||||
if (operand) {
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type(operand);
|
||||
}
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function applyNumberFilter(page: Page, columnId: string, operator: string, operand: number) {
|
||||
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
|
||||
if (!(await existingFilter.isVisible())) {
|
||||
await selectFilter(page, columnId);
|
||||
}
|
||||
await openFilter(page, columnId);
|
||||
|
||||
await page.getByTestId(`number-filter-options-select-${columnId}`).click();
|
||||
await page
|
||||
.locator(`[data-testid="number-filter-options-${columnId}"] [id^="react-select-"]`)
|
||||
.getByText(operator)
|
||||
.first()
|
||||
.click();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type(String(operand));
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
applyFilter,
|
||||
applySelectFilter,
|
||||
createFilterSegment,
|
||||
selectSegment,
|
||||
deleteSegment,
|
||||
@@ -56,7 +56,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
const segmentName = "Admin Users";
|
||||
|
||||
await test.step("Can apply and save a role filter as a segment", async () => {
|
||||
await applyFilter(page, "role", "admin");
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
|
||||
await expect(page.getByText(adminUser.email)).toBeVisible();
|
||||
await expect(page.getByText(memberUser.email)).toBeHidden();
|
||||
@@ -110,7 +110,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
const dataTable = page.getByTestId("user-list-data-table");
|
||||
await expect(dataTable).toBeVisible();
|
||||
|
||||
await applyFilter(page, "role", "admin");
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
const segmentName = "Admin Users Persistent";
|
||||
await createFilterSegment(page, segmentName);
|
||||
|
||||
@@ -158,7 +158,7 @@ test.describe("Filter Segment Functionality", () => {
|
||||
const segmentName = "Team Admin Filter";
|
||||
|
||||
await test.step("Can create a team scope filter segment", async () => {
|
||||
await applyFilter(page, "role", "admin");
|
||||
await applySelectFilter(page, "role", "admin");
|
||||
|
||||
await createFilterSegment(page, segmentName, {
|
||||
teamScope: true,
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
selectFilter,
|
||||
openFilter,
|
||||
clearFilters,
|
||||
applySelectFilter,
|
||||
applyTextFilter,
|
||||
applyNumberFilter,
|
||||
selectOptionValue,
|
||||
} from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Insights > Routing Filters", () => {
|
||||
test("formId filter: should filter by selected routing form", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName1 = "Test Form 1";
|
||||
const form1 = await routingForms.create({
|
||||
name: formName1,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formName2 = "Test Form 2";
|
||||
const form2 = await routingForms.create({
|
||||
name: formName2,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Email",
|
||||
identifier: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await openFilter(page, "formId");
|
||||
await selectOptionValue(page, "formId", formName1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText(formName1)).toBeVisible();
|
||||
await expect(page.getByText(formName2)).toBeHidden();
|
||||
|
||||
await openFilter(page, "formId");
|
||||
await selectOptionValue(page, "formId", formName2);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText(formName2)).toBeVisible();
|
||||
await expect(page.getByText(formName1)).toBeHidden();
|
||||
});
|
||||
|
||||
test("bookingUserId filter: should filter by user", async ({ page, users, routingForms, prisma }) => {
|
||||
const owner = await users.create(
|
||||
{ name: "owner" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
}
|
||||
);
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const user1Name = "User One";
|
||||
const user1 = await users.create({ name: user1Name });
|
||||
const user2 = await users.create({ name: "User Two" });
|
||||
await prisma.membership.createMany({
|
||||
data: [
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
userId: user1.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
userId: user2.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const formName = "Filter User Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const booking1 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 1",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user1.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user2.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "John Doe",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: booking1.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "Jane Smith",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: booking2.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await applySelectFilter(page, "bookingUserId", user1Name);
|
||||
|
||||
await expect(page.getByText("John Doe")).toBeVisible();
|
||||
await expect(page.getByText("Jane Smith")).toBeHidden();
|
||||
|
||||
await prisma.booking.delete({ where: { id: booking1.id } });
|
||||
await prisma.booking.delete({ where: { id: booking2.id } });
|
||||
});
|
||||
|
||||
// We will add it back when we enable the bookingAttendees filter on /insights/routing
|
||||
test.skip("bookingAttendees filter: should filter by attendee name or email", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName = "Attendee Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
const booking1 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 1",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: owner.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
attendees: {
|
||||
create: [
|
||||
{
|
||||
email: "john.doe@example.com",
|
||||
name: "John Doe",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-${uuidv4()}`,
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: owner.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
attendees: {
|
||||
create: [
|
||||
{
|
||||
email: "jane.smith@example.com",
|
||||
name: "Jane Smith",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: { [fieldId]: { label: "Name", value: "Response 1" } },
|
||||
routedToBookingUid: booking1.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: { [fieldId]: { label: "Name", value: "Response 2" } },
|
||||
routedToBookingUid: booking2.uid,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await selectFilter(page, "bookingAttendees");
|
||||
await openFilter(page, "bookingAttendees");
|
||||
await page.getByPlaceholder("Filter attendees by name or email").fill("John");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByText("Response 1")).toBeVisible();
|
||||
await expect(page.getByText("Response 2")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await selectFilter(page, "bookingAttendees");
|
||||
await openFilter(page, "bookingAttendees");
|
||||
await page.getByPlaceholder("Filter attendees by name or email").fill("jane.smith@example");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByText("Response 2")).toBeVisible();
|
||||
await expect(page.getByText("Response 1")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await selectFilter(page, "bookingAttendees");
|
||||
await openFilter(page, "bookingAttendees");
|
||||
await page.getByPlaceholder("Filter attendees by name or email").fill("JANE");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByText("Response 2")).toBeVisible();
|
||||
await expect(page.getByText("Response 1")).toBeHidden();
|
||||
|
||||
await prisma.booking.delete({ where: { id: booking1.id } });
|
||||
await prisma.booking.delete({ where: { id: booking2.id } });
|
||||
});
|
||||
|
||||
test("TEXT field: should filter with different text operators", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const textFieldId = uuidv4();
|
||||
const formName = "Text Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Description",
|
||||
identifier: "description",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Description",
|
||||
value: "This is a test description",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Description",
|
||||
value: "Another description for testing",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-3",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Description",
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await applyTextFilter(page, fieldId, "Contains", "test");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeVisible();
|
||||
await expect(page.getByText("Another description for testing")).toBeVisible();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyTextFilter(page, fieldId, "Is", "This is a test description");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeVisible();
|
||||
await expect(page.getByText("Another description for testing")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyTextFilter(page, fieldId, "Is empty");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeHidden();
|
||||
await expect(page.getByText("Another description for testing")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyTextFilter(page, fieldId, "Contains", "TEST");
|
||||
|
||||
await expect(page.getByText("This is a test description")).toBeVisible();
|
||||
await expect(page.getByText("Another description for testing")).toBeVisible();
|
||||
});
|
||||
|
||||
test("NUMBER field: should filter with different number operators", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName = "Number Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "number",
|
||||
label: "Rating",
|
||||
identifier: "rating",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Rating",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Rating",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-3",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Rating",
|
||||
value: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await applyNumberFilter(page, fieldId, "=", 3);
|
||||
|
||||
const getByNumber = (number: number) =>
|
||||
page.locator(`[data-testid="data-table-td-${fieldId}"]`).getByText(String(number));
|
||||
|
||||
await expect(getByNumber(3)).toBeVisible();
|
||||
await expect(getByNumber(1)).toBeHidden();
|
||||
await expect(getByNumber(5)).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyNumberFilter(page, fieldId, ">", 3);
|
||||
|
||||
await expect(getByNumber(5)).toBeVisible();
|
||||
await expect(getByNumber(1)).toBeHidden();
|
||||
await expect(getByNumber(3)).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applyNumberFilter(page, fieldId, "≤", 3);
|
||||
|
||||
await expect(getByNumber(1)).toBeVisible();
|
||||
await expect(getByNumber(3)).toBeVisible();
|
||||
await expect(getByNumber(5)).toBeHidden();
|
||||
});
|
||||
|
||||
test("SINGLE_SELECT and MULTI_SELECT fields: should filter correctly", async ({
|
||||
page,
|
||||
users,
|
||||
routingForms,
|
||||
prisma,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const singleSelectFieldId = uuidv4();
|
||||
const multiSelectFieldId = uuidv4();
|
||||
const formName = "Select Filter Test Form";
|
||||
|
||||
const locationOptionId1 = uuidv4();
|
||||
const locationOptionId2 = uuidv4();
|
||||
|
||||
const skillOptionId1 = uuidv4();
|
||||
const skillOptionId2 = uuidv4();
|
||||
const skillOptionId3 = uuidv4();
|
||||
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "Location",
|
||||
identifier: "location",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: locationOptionId1,
|
||||
label: "New York",
|
||||
},
|
||||
{
|
||||
id: locationOptionId2,
|
||||
label: "London",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "multiselect",
|
||||
label: "Skills",
|
||||
identifier: "skills",
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: skillOptionId1,
|
||||
label: "JavaScript",
|
||||
},
|
||||
{
|
||||
id: skillOptionId2,
|
||||
label: "TypeScript",
|
||||
},
|
||||
{
|
||||
id: skillOptionId3,
|
||||
label: "React",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const locationFieldId = (form.fields as any)[0].id;
|
||||
const skillFieldId = (form.fields as any)[1].id;
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-1",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[locationFieldId]: {
|
||||
label: "Location",
|
||||
value: locationOptionId1,
|
||||
},
|
||||
[skillFieldId]: {
|
||||
label: "Skills",
|
||||
value: [skillOptionId1, skillOptionId2],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-2",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[locationFieldId]: {
|
||||
label: "Location",
|
||||
value: locationOptionId2,
|
||||
},
|
||||
[skillFieldId]: {
|
||||
label: "Skills",
|
||||
value: [skillOptionId2, skillOptionId3],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
const getByColumnValue = (columnId: string, value: string) =>
|
||||
page.locator(`[data-testid="data-table-td-${columnId}"]`).getByText(value);
|
||||
|
||||
await applySelectFilter(page, locationFieldId, "New York");
|
||||
await expect(getByColumnValue(locationFieldId, "New York")).toBeVisible();
|
||||
await expect(getByColumnValue(locationFieldId, "London")).toBeHidden();
|
||||
|
||||
await clearFilters(page);
|
||||
await applySelectFilter(page, skillFieldId, "JavaScript");
|
||||
await applySelectFilter(page, skillFieldId, "TypeScript");
|
||||
await expect(getByColumnValue(skillFieldId, "JavaScript")).toBeVisible();
|
||||
await expect(getByColumnValue(skillFieldId, "TypeScript")).toBeVisible();
|
||||
await expect(getByColumnValue(skillFieldId, "React")).toBeHidden();
|
||||
});
|
||||
|
||||
test("createdAt filter: should filter by date range", async ({ page, users, routingForms, prisma }) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
});
|
||||
await owner.apiLogin();
|
||||
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
|
||||
const formName = "Date Range Filter Test Form";
|
||||
const form = await routingForms.create({
|
||||
name: formName,
|
||||
userId: owner.id,
|
||||
teamId: membership.teamId,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Name",
|
||||
identifier: "name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fieldId = (form.fields as any)[0].id;
|
||||
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 10);
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-old",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "Old Response",
|
||||
},
|
||||
},
|
||||
createdAt: oldDate,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-recent",
|
||||
formId: form.id,
|
||||
response: {
|
||||
[fieldId]: {
|
||||
label: "Name",
|
||||
value: "Recent Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/insights/routing`);
|
||||
|
||||
await openFilter(page, "createdAt");
|
||||
await page.getByTestId("date-range-options-w").click(); // Last 7 Days
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText("Recent Response")).toBeVisible();
|
||||
await expect(page.getByText("Old Response")).toBeHidden();
|
||||
|
||||
await openFilter(page, "createdAt");
|
||||
await page.getByTestId("date-range-options-t").click(); // Last 30 Days
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText("Recent Response")).toBeVisible();
|
||||
await expect(page.getByText("Old Response")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1779,6 +1779,7 @@
|
||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||
"attendee_email_variable": "Attendee email",
|
||||
"attendee_email_info": "The person booking's email",
|
||||
"booking_uid": "Booking UID",
|
||||
"kbar_search_placeholder": "Type a command or search...",
|
||||
"invalid_credential": "It looks like permissions expired or were revoked for {{appName}}.",
|
||||
"invalid_credential_action": "Reinstall app",
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
}
|
||||
],
|
||||
"functions": {
|
||||
"pages/api/trpc/workflows/[trpc].ts": {
|
||||
"maxDuration": 800
|
||||
},
|
||||
"pages/api/trpc/highPerf/[trpc].ts": {
|
||||
"memory": 3008
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -974,15 +974,15 @@ export default class GoogleCalendarService implements Calendar {
|
||||
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
|
||||
}
|
||||
: {};
|
||||
let error: string | undefined;
|
||||
let error: string | null = null;
|
||||
|
||||
if (!otherCalendarsWithSameSubscription.length) {
|
||||
try {
|
||||
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to watch calendar ${calendarId}`, error);
|
||||
} catch (_error) {
|
||||
this.log.error(`Failed to watch calendar ${calendarId}`, _error);
|
||||
// We set error to prevent attempting to watch on next cron run
|
||||
error = error instanceof Error ? error.message : "Unknown error";
|
||||
error = _error instanceof Error ? _error.message : "Unknown error";
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
|
||||
@@ -15,7 +15,9 @@ import { getQueryParam } from "../../utils/query-param";
|
||||
|
||||
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 isEmbed = useIsEmbed();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
@@ -31,7 +33,7 @@ export const useBookerLayout = (event: Pick<BookerEvent, "profile"> | undefined
|
||||
const columnViewExtraDays = useRef<number>(
|
||||
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
|
||||
);
|
||||
const bookerLayouts = event?.profile?.bookerLayouts || defaultBookerLayoutSettings;
|
||||
const bookerLayouts = profileBookerLayouts || defaultBookerLayoutSettings;
|
||||
const defaultLayout = isEmbed
|
||||
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
|
||||
: bookerLayouts.defaultLayout;
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface IUseBookingForm {
|
||||
guests: string[];
|
||||
name: string | null;
|
||||
};
|
||||
lastBookingResponse?: Record<string, string>;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
@@ -36,7 +35,6 @@ export const useBookingForm = ({
|
||||
hasSession,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
lastBookingResponse,
|
||||
clientId,
|
||||
}: IUseBookingForm) => {
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
@@ -76,7 +74,6 @@ export const useBookingForm = ({
|
||||
hasSession,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
lastBookingResponse,
|
||||
clientId,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ type UseInitialFormValuesProps = {
|
||||
guests: string[];
|
||||
name: string | null;
|
||||
};
|
||||
lastBookingResponse?: Record<string, string>;
|
||||
clientId?: string;
|
||||
};
|
||||
|
||||
@@ -63,7 +62,6 @@ export function useInitialFormValues({
|
||||
hasSession,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
lastBookingResponse,
|
||||
clientId,
|
||||
}: UseInitialFormValuesProps) {
|
||||
const stableHashExtraOptions = getStableHash(extraOptions);
|
||||
@@ -105,7 +103,6 @@ export function useInitialFormValues({
|
||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||
guests: prefillFormParams.guests,
|
||||
});
|
||||
const parsedLastBookingResponse = z.record(z.any()).nullish().parse(lastBookingResponse);
|
||||
|
||||
const defaultUserValues = {
|
||||
email:
|
||||
@@ -113,13 +110,13 @@ export function useInitialFormValues({
|
||||
? bookingData?.attendees[0].email
|
||||
: !!parsedQuery["email"]
|
||||
? parsedQuery["email"]
|
||||
: email ?? parsedLastBookingResponse?.email ?? "",
|
||||
: email ?? "",
|
||||
name:
|
||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||
? bookingData?.attendees[0].name
|
||||
: !!parsedQuery["name"]
|
||||
? parsedQuery["name"]
|
||||
: name ?? username ?? parsedLastBookingResponse?.name ?? "",
|
||||
: name ?? username ?? "",
|
||||
};
|
||||
|
||||
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 {};
|
||||
// }
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
@@ -82,6 +83,13 @@ const handleCalendarsToUnwatch = async () => {
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
log.error(
|
||||
"Error unwatching calendar: ",
|
||||
safeStringify({
|
||||
selectedCalendarId: id,
|
||||
error: errorMessage,
|
||||
})
|
||||
);
|
||||
await SelectedCalendarRepository.updateById(id, {
|
||||
error: `Error unwatching calendar: ${errorMessage}`,
|
||||
});
|
||||
@@ -117,6 +125,13 @@ const handleCalendarsToWatch = async () => {
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
log.error(
|
||||
"Error watching calendar: ",
|
||||
safeStringify({
|
||||
selectedCalendarId: id,
|
||||
error: errorMessage,
|
||||
})
|
||||
);
|
||||
await SelectedCalendarRepository.updateById(id, {
|
||||
error: `Error watching calendar: ${errorMessage}`,
|
||||
});
|
||||
|
||||
@@ -91,7 +91,9 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
|
||||
log.debug("Getting cached availability", safeStringify({ credentialId, userId, args }));
|
||||
const key = parseKeyForCache(args);
|
||||
let cached;
|
||||
let usedInMemoryDelegationCredential = false;
|
||||
if (isInMemoryDelegationCredential({ credentialId })) {
|
||||
usedInMemoryDelegationCredential = true;
|
||||
if (!userId) {
|
||||
log.warn("userId is not available when querying cache for in-memory delegation credential");
|
||||
return null;
|
||||
@@ -109,6 +111,11 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
|
||||
key,
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
async upsertCachedAvailability({
|
||||
|
||||
@@ -74,6 +74,7 @@ interface DataTableProviderProps {
|
||||
children: React.ReactNode;
|
||||
ctaContainerClassName?: string;
|
||||
defaultPageSize?: number;
|
||||
segments?: FilterSegmentOutput[];
|
||||
}
|
||||
|
||||
export function DataTableProvider({
|
||||
@@ -82,6 +83,7 @@ export function DataTableProvider({
|
||||
useSegments = useSegmentsNoop,
|
||||
defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
|
||||
segments: providedSegments,
|
||||
}: DataTableProviderProps) {
|
||||
const filterToOpen = useRef<string | undefined>(undefined);
|
||||
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
@@ -182,6 +184,7 @@ export function DataTableProvider({
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
segments: providedSegments,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ function DataTableBody<TData>({
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
data-testid={`data-table-td-${cell.column.id}`}
|
||||
style={{
|
||||
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
|
||||
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-2">
|
||||
<div className="mx-3 my-2" data-testid={`number-filter-options-${column.id}`}>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={({ operatorOption, operand }) => {
|
||||
@@ -51,6 +51,7 @@ export function NumberFilterOptions({ column }: NumberFilterOptionsProps) {
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
data-testid={`number-filter-options-select-${column.id}`}
|
||||
className="basis-1/3"
|
||||
options={numberFilterOperatorOptions}
|
||||
value={value}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TextFilterOptions({ column }: TextFilterOptionsProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-2">
|
||||
<div className="mx-3 my-2" data-testid={`text-filter-options-${column.id}`}>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={({ operatorOption, operand }) => {
|
||||
@@ -52,6 +52,7 @@ export function TextFilterOptions({ column }: TextFilterOptionsProps) {
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Select
|
||||
data-testid={`text-filter-options-select-${column.id}`}
|
||||
options={textFilterOperatorOptions}
|
||||
value={value}
|
||||
isSearchable={false}
|
||||
|
||||
@@ -26,15 +26,24 @@ export const useSegments: UseSegments = ({
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
segments: providedSegments,
|
||||
}) => {
|
||||
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery({
|
||||
tableIdentifier,
|
||||
});
|
||||
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
|
||||
{
|
||||
tableIdentifier,
|
||||
},
|
||||
{
|
||||
enabled: !providedSegments, // Only fetch if segments are not provided
|
||||
}
|
||||
);
|
||||
|
||||
// Recalculate date ranges based on the current timestamp
|
||||
const segments = useMemo(() => {
|
||||
if (!rawSegments) return [];
|
||||
return rawSegments.map((segment) => ({
|
||||
const segmentsSource = providedSegments || rawSegments;
|
||||
|
||||
if (!segmentsSource) return [];
|
||||
|
||||
return segmentsSource.map((segment) => ({
|
||||
...segment,
|
||||
activeFilters: segment.activeFilters.map((filter) => {
|
||||
if (isDateRangeFilterValue(filter.v)) {
|
||||
@@ -46,7 +55,7 @@ export const useSegments: UseSegments = ({
|
||||
return filter;
|
||||
}),
|
||||
}));
|
||||
}, [rawSegments]);
|
||||
}, [rawSegments, providedSegments]);
|
||||
|
||||
const selectedSegment = useMemo(
|
||||
() => segments?.find((segment) => segment.id === segmentId),
|
||||
|
||||
@@ -270,7 +270,7 @@ export const ZSegmentStorage = z.record(
|
||||
|
||||
export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
|
||||
|
||||
type UseSegmentsProps = {
|
||||
export type UseSegmentsProps = {
|
||||
tableIdentifier: string;
|
||||
activeFilters: ActiveFilters;
|
||||
sorting: SortingState;
|
||||
@@ -288,9 +288,10 @@ type UseSegmentsProps = {
|
||||
setPageSize: (pageSize: number) => void;
|
||||
setPageIndex: (pageIndex: number) => void;
|
||||
setSearchTerm: (searchTerm: string | null) => void;
|
||||
segments?: FilterSegmentOutput[];
|
||||
};
|
||||
|
||||
type UseSegmentsReturn = {
|
||||
export type UseSegmentsReturn = {
|
||||
segments: FilterSegmentOutput[];
|
||||
selectedSegment: FilterSegmentOutput | undefined;
|
||||
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 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
|
||||
|
||||
@@ -109,7 +111,7 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
|
||||
|
||||
### 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.
|
||||
- `selected-calendars` cron job creates SelectedCalendar records for all the Delegation User Credentials of the organization who don't have Selected Calendars yet.
|
||||
@@ -137,6 +139,7 @@ Disabling effectively stops generating in-memory delegation user credentials. So
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
### Notes when testing locally
|
||||
@@ -146,8 +149,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.
|
||||
- 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?
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* How to verify that rate limit is different for different impersonated users?
|
||||
* Open two terminals
|
||||
* Terminal 1: Run `node rate-limit-test-google-service-account-impersonation.js email1@cal.com 1000` - This makes email1 hit the limit. 1000 is much more than 600, so it gives more reliability in reaching the limit.
|
||||
* Terminal 1: Run `node rate-limit-test-google-service-account-impersonation.js email1@cal.com 100` - Verify that email1 is still hitting the limit
|
||||
* // Run this immediately after the above commands
|
||||
* Terminal 2: Run `node rate-limit-test-google-service-account-impersonation.js email2@cal.com 100` - Check for email2's limit, it should not hit the limit
|
||||
*/
|
||||
const { calendar_v3 } = require("@googleapis/calendar");
|
||||
const { JWT } = require("googleapis-common");
|
||||
|
||||
// client_email in the service account key json file
|
||||
const serviceAccountClientEmail = "";
|
||||
// private_key in the service account key json file
|
||||
const serviceAccountPrivateKey = ``;
|
||||
|
||||
if (!serviceAccountClientEmail || !serviceAccountPrivateKey) {
|
||||
throw new Error("serviceAccountClientEmail and serviceAccountPrivateKey must be set");
|
||||
}
|
||||
|
||||
async function sendRequest(calendar) {
|
||||
const response = await calendar.calendarList.list({
|
||||
fields: `items(id),nextPageToken`,
|
||||
});
|
||||
console.log(response.data);
|
||||
}
|
||||
const args = process.argv.slice(2);
|
||||
const emailToImpersonate = args[0];
|
||||
const totalRequestsArg = args[1];
|
||||
(async function () {
|
||||
console.log({ emailToImpersonate, totalRequestsArg });
|
||||
const authClient = new JWT({
|
||||
email: serviceAccountClientEmail,
|
||||
key: serviceAccountPrivateKey,
|
||||
scopes: ["https://www.googleapis.com/auth/calendar"],
|
||||
subject: emailToImpersonate,
|
||||
});
|
||||
|
||||
await authClient.authorize();
|
||||
const calendar = new calendar_v3.Calendar({
|
||||
auth: authClient,
|
||||
});
|
||||
|
||||
// 600 requests per minute is the API limit per user
|
||||
const totalRequests = parseInt(totalRequestsArg) || 600;
|
||||
const promises = [];
|
||||
for (let i = 0; i < totalRequests; i++) {
|
||||
console.log(`Queuing request ${i + 1}/${totalRequests}`);
|
||||
promises.push(sendRequest(calendar));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
console.log("All requests completed");
|
||||
})();
|
||||
@@ -6,12 +6,23 @@ import * as EmailManager from "@calcom/emails/email-manager";
|
||||
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { CreditType } from "@calcom/prisma/enums";
|
||||
|
||||
import { CreditService } from "./credit-service";
|
||||
import { CreditService as OriginalCreditService } from "./credit-service";
|
||||
import { StripeBillingService } from "./stripe-billling-service";
|
||||
import { InternalTeamBilling } from "./teams/internal-team-billing";
|
||||
|
||||
const MOCK_TX = prisma;
|
||||
|
||||
vi.mock("@calcom/prisma", () => {
|
||||
return {
|
||||
default: {
|
||||
$transaction: vi.fn((fn) => fn(MOCK_TX)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("stripe");
|
||||
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
@@ -41,6 +52,39 @@ vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
|
||||
cancelScheduledMessagesAndScheduleEmails: vi.fn(),
|
||||
}));
|
||||
|
||||
// Create a testable version of CreditService that exposes protected methods
|
||||
class CreditService extends OriginalCreditService {
|
||||
public async _getTeamWithAvailableCredits(
|
||||
params: Parameters<OriginalCreditService["_getTeamWithAvailableCredits"]>[0]
|
||||
) {
|
||||
return super._getTeamWithAvailableCredits(params);
|
||||
}
|
||||
|
||||
public async _getUserOrTeamToCharge(
|
||||
params: Parameters<OriginalCreditService["_getUserOrTeamToCharge"]>[0]
|
||||
) {
|
||||
return super._getUserOrTeamToCharge(params);
|
||||
}
|
||||
|
||||
public async _createExpenseLog(props: Parameters<OriginalCreditService["_createExpenseLog"]>[0]) {
|
||||
return super._createExpenseLog(props);
|
||||
}
|
||||
|
||||
public async _handleLowCreditBalance(
|
||||
params: Parameters<OriginalCreditService["_handleLowCreditBalance"]>[0]
|
||||
) {
|
||||
return super._handleLowCreditBalance(params);
|
||||
}
|
||||
|
||||
public async _getAllCreditsForTeam(params: Parameters<OriginalCreditService["_getAllCreditsForTeam"]>[0]) {
|
||||
return super._getAllCreditsForTeam(params);
|
||||
}
|
||||
|
||||
public async _getAllCredits(params: Parameters<OriginalCreditService["_getAllCredits"]>[0]) {
|
||||
return super._getAllCredits(params);
|
||||
}
|
||||
}
|
||||
|
||||
describe("CreditService", () => {
|
||||
let creditService: CreditService;
|
||||
let stripeMock: any;
|
||||
@@ -182,11 +226,11 @@ describe("CreditService", () => {
|
||||
user: null,
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 20,
|
||||
additionalCredits: 60,
|
||||
@@ -206,7 +250,8 @@ describe("CreditService", () => {
|
||||
creditType: CreditType.MONTHLY,
|
||||
credits: 5,
|
||||
smsSid: "sms-123",
|
||||
})
|
||||
}),
|
||||
MOCK_TX
|
||||
);
|
||||
|
||||
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
|
||||
@@ -243,7 +288,7 @@ describe("CreditService", () => {
|
||||
|
||||
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: -1,
|
||||
additionalCredits: 0,
|
||||
@@ -263,7 +308,8 @@ describe("CreditService", () => {
|
||||
creditType: CreditType.ADDITIONAL,
|
||||
credits: 5,
|
||||
smsSid: "sms-123",
|
||||
})
|
||||
}),
|
||||
MOCK_TX
|
||||
);
|
||||
|
||||
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
|
||||
@@ -272,7 +318,7 @@ describe("CreditService", () => {
|
||||
|
||||
describe("getUserOrTeamToCharge", () => {
|
||||
it("should return team with remaining credits when teamId is provided", async () => {
|
||||
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 100,
|
||||
additionalCredits: 50,
|
||||
@@ -291,7 +337,7 @@ describe("CreditService", () => {
|
||||
});
|
||||
|
||||
it("should use additional credits when monthly credits are out", async () => {
|
||||
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: 50,
|
||||
@@ -319,7 +365,7 @@ describe("CreditService", () => {
|
||||
warningSentAt: null,
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue({
|
||||
teamId: 1,
|
||||
availableCredits: 150,
|
||||
creditType: CreditType.MONTHLY,
|
||||
@@ -429,7 +475,7 @@ describe("CreditService", () => {
|
||||
limitReachedAt: null,
|
||||
warningSentAt: null,
|
||||
});
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
|
||||
const hasAvailableCredits = await creditService.hasAvailableCredits({ userId: 1 });
|
||||
expect(hasAvailableCredits).toBe(true);
|
||||
@@ -445,7 +491,7 @@ describe("CreditService", () => {
|
||||
warningSentAt: null,
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
|
||||
const result = await creditService.hasAvailableCredits({ userId: 1 });
|
||||
expect(result).toBe(false);
|
||||
@@ -475,11 +521,11 @@ describe("CreditService", () => {
|
||||
team: null,
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
||||
totalMonthlyCredits: 0,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: 10,
|
||||
@@ -499,7 +545,8 @@ describe("CreditService", () => {
|
||||
creditType: CreditType.ADDITIONAL,
|
||||
credits: 5,
|
||||
smsSid: "sms-123",
|
||||
})
|
||||
}),
|
||||
MOCK_TX
|
||||
);
|
||||
|
||||
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
|
||||
@@ -537,9 +584,9 @@ describe("CreditService", () => {
|
||||
|
||||
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
||||
totalMonthlyCredits: 0,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: 2,
|
||||
@@ -559,7 +606,8 @@ describe("CreditService", () => {
|
||||
creditType: CreditType.ADDITIONAL,
|
||||
credits: 5,
|
||||
smsSid: "sms-123",
|
||||
})
|
||||
}),
|
||||
MOCK_TX
|
||||
);
|
||||
|
||||
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
|
||||
@@ -568,9 +616,9 @@ describe("CreditService", () => {
|
||||
|
||||
describe("getUserOrTeamToCharge", () => {
|
||||
it("should return user with remaining credits when userId is provided", async () => {
|
||||
vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
vi.spyOn(CreditService.prototype, "_getTeamWithAvailableCredits").mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({
|
||||
vi.spyOn(CreditService.prototype, "_getAllCredits").mockResolvedValue({
|
||||
totalMonthlyCredits: 0,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: 10,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
sendCreditBalanceLimitReachedEmails,
|
||||
@@ -12,10 +14,43 @@ import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma, { type PrismaTransaction } from "@calcom/prisma";
|
||||
import { CreditType } from "@calcom/prisma/enums";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[CreditService]"] });
|
||||
|
||||
type LowCreditBalanceResultBase = {
|
||||
team?: {
|
||||
id: number;
|
||||
name: string;
|
||||
adminAndOwners: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
t: TFunction;
|
||||
}[];
|
||||
};
|
||||
user?: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
t: TFunction;
|
||||
};
|
||||
};
|
||||
|
||||
type LowCreditBalanceLimitReachedResult = LowCreditBalanceResultBase & {
|
||||
type: "LIMIT_REACHED";
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
};
|
||||
|
||||
type LowCreditBalanceWarningResult = LowCreditBalanceResultBase & {
|
||||
type: "WARNING";
|
||||
balance: number;
|
||||
};
|
||||
|
||||
type LowCreditBalanceResult = LowCreditBalanceLimitReachedResult | LowCreditBalanceWarningResult | null;
|
||||
|
||||
export class CreditService {
|
||||
async chargeCredits({
|
||||
userId,
|
||||
@@ -30,90 +65,122 @@ export class CreditService {
|
||||
bookingUid?: string;
|
||||
smsSid?: string;
|
||||
}) {
|
||||
let teamIdToCharge = credits === 0 && teamId ? teamId : undefined;
|
||||
let creditType: CreditType = CreditType.ADDITIONAL;
|
||||
let remainingCredits;
|
||||
let userIdToCharge;
|
||||
if (!teamIdToCharge) {
|
||||
const result = await this.getUserOrTeamToCharge({
|
||||
credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available
|
||||
userId,
|
||||
teamId,
|
||||
return await prisma
|
||||
.$transaction(async (tx) => {
|
||||
let teamIdToCharge = credits === 0 && teamId ? teamId : undefined;
|
||||
let creditType: CreditType = CreditType.ADDITIONAL;
|
||||
let remainingCredits;
|
||||
let userIdToCharge;
|
||||
if (!teamIdToCharge) {
|
||||
const result = await this._getUserOrTeamToCharge({
|
||||
credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available
|
||||
userId,
|
||||
teamId,
|
||||
tx,
|
||||
});
|
||||
teamIdToCharge = result?.teamId;
|
||||
userIdToCharge = result?.userId;
|
||||
creditType = result?.creditType ?? creditType;
|
||||
remainingCredits = result?.remainingCredits;
|
||||
}
|
||||
|
||||
if (!teamIdToCharge && !userIdToCharge) {
|
||||
log.error("No team or user found to charge. No credit expense log created");
|
||||
return null;
|
||||
}
|
||||
|
||||
await this._createExpenseLog({
|
||||
bookingUid,
|
||||
smsSid,
|
||||
teamId: teamIdToCharge,
|
||||
userId: userIdToCharge,
|
||||
credits,
|
||||
creditType,
|
||||
tx,
|
||||
});
|
||||
|
||||
let lowCreditBalanceResult = null;
|
||||
if (credits) {
|
||||
lowCreditBalanceResult = await this._handleLowCreditBalance({
|
||||
teamId: teamIdToCharge,
|
||||
userId: userIdToCharge,
|
||||
remainingCredits: remainingCredits ?? 0,
|
||||
tx,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
teamId: teamIdToCharge,
|
||||
userId: userIdToCharge,
|
||||
lowCreditBalanceResult,
|
||||
};
|
||||
})
|
||||
.then(async (result) => {
|
||||
if (result?.lowCreditBalanceResult) {
|
||||
// send emails after transaction is successfully committed
|
||||
await this._handleLowCreditBalanceResult(result.lowCreditBalanceResult);
|
||||
}
|
||||
return {
|
||||
teamId: result?.teamId,
|
||||
userId: result?.userId,
|
||||
};
|
||||
});
|
||||
teamIdToCharge = result?.teamId;
|
||||
userIdToCharge = result?.userId;
|
||||
creditType = result?.creditType ?? creditType;
|
||||
remainingCredits = result?.remainingCredits;
|
||||
}
|
||||
|
||||
if (!teamIdToCharge && !userIdToCharge) {
|
||||
log.error("No team or user found to charge. No credit expense log created");
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.createExpenseLog({
|
||||
bookingUid,
|
||||
smsSid,
|
||||
teamId: teamIdToCharge,
|
||||
userId: userIdToCharge,
|
||||
credits,
|
||||
creditType,
|
||||
});
|
||||
|
||||
if (credits) {
|
||||
await this.handleLowCreditBalance({
|
||||
teamId: teamIdToCharge,
|
||||
userId: userIdToCharge,
|
||||
remainingCredits: remainingCredits ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return { teamId: teamIdToCharge, userId: userIdToCharge };
|
||||
}
|
||||
|
||||
async hasAvailableCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
|
||||
if (!IS_SMS_CREDITS_ENABLED) return true;
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (!IS_SMS_CREDITS_ENABLED) return true;
|
||||
|
||||
if (teamId) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalance({ teamId });
|
||||
if (teamId) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalance({ teamId }, tx);
|
||||
|
||||
const limitReached =
|
||||
creditBalance?.limitReachedAt &&
|
||||
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
|
||||
const limitReached =
|
||||
creditBalance?.limitReachedAt &&
|
||||
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
|
||||
|
||||
if (!limitReached) return true;
|
||||
if (!limitReached) return true;
|
||||
|
||||
// check if team is still out of credits
|
||||
const teamCredits = await this.getAllCreditsForTeam(teamId);
|
||||
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
|
||||
// check if team is still out of credits
|
||||
const teamCredits = await this._getAllCreditsForTeam({ teamId, tx });
|
||||
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
|
||||
|
||||
if (availableCredits > 0) {
|
||||
await CreditsRepository.updateCreditBalance({
|
||||
teamId,
|
||||
data: {
|
||||
limitReachedAt: null,
|
||||
warningSentAt: null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
if (availableCredits > 0) {
|
||||
await CreditsRepository.updateCreditBalance(
|
||||
{
|
||||
teamId,
|
||||
data: {
|
||||
limitReachedAt: null,
|
||||
warningSentAt: null,
|
||||
},
|
||||
},
|
||||
tx
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const teamWithAvailableCredits = await this.getTeamWithAvailableCredits(userId);
|
||||
if (userId) {
|
||||
const teamWithAvailableCredits = await this._getTeamWithAvailableCredits({ userId, tx });
|
||||
|
||||
if (teamWithAvailableCredits && teamWithAvailableCredits?.availableCredits > 0) return true;
|
||||
if (teamWithAvailableCredits && teamWithAvailableCredits?.availableCredits > 0) return true;
|
||||
|
||||
const userCredits = await this.getAllCredits({ userId });
|
||||
const userCredits = await this._getAllCredits({ userId, tx });
|
||||
|
||||
return userCredits.additionalCredits > 0;
|
||||
}
|
||||
return userCredits.additionalCredits > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamWithAvailableCredits(userId: number) {
|
||||
const memberships = await MembershipRepository.findAllAcceptedMemberships(userId);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return this._getTeamWithAvailableCredits({ userId, tx });
|
||||
});
|
||||
}
|
||||
|
||||
protected async _getTeamWithAvailableCredits({ userId, tx }: { userId: number; tx: PrismaTransaction }) {
|
||||
const memberships = await MembershipRepository.findAllAcceptedMemberships(userId, tx);
|
||||
|
||||
if (memberships.length === 0) {
|
||||
return null;
|
||||
@@ -121,9 +188,9 @@ export class CreditService {
|
||||
|
||||
//check if user is member of team that has available credits
|
||||
for (const membership of memberships) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId });
|
||||
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx);
|
||||
|
||||
const allCredits = await this.getAllCreditsForTeam(membership.teamId);
|
||||
const allCredits = await this._getAllCreditsForTeam({ teamId: membership.teamId, tx });
|
||||
const limitReached =
|
||||
creditBalance?.limitReachedAt &&
|
||||
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
|
||||
@@ -132,13 +199,16 @@ export class CreditService {
|
||||
|
||||
if (!limitReached || availableCredits > 0) {
|
||||
if (limitReached) {
|
||||
await CreditsRepository.updateCreditBalance({
|
||||
teamId: membership.teamId,
|
||||
data: {
|
||||
limitReachedAt: null,
|
||||
warningSentAt: null,
|
||||
await CreditsRepository.updateCreditBalance(
|
||||
{
|
||||
teamId: membership.teamId,
|
||||
data: {
|
||||
limitReachedAt: null,
|
||||
warningSentAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
tx
|
||||
);
|
||||
}
|
||||
return {
|
||||
teamId: membership.teamId,
|
||||
@@ -167,9 +237,25 @@ export class CreditService {
|
||||
credits: number;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return this._getUserOrTeamToCharge({ credits, userId, teamId, tx });
|
||||
});
|
||||
}
|
||||
|
||||
protected async _getUserOrTeamToCharge({
|
||||
credits,
|
||||
userId,
|
||||
teamId,
|
||||
tx,
|
||||
}: {
|
||||
credits: number;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
tx: PrismaTransaction;
|
||||
}) {
|
||||
if (teamId) {
|
||||
const teamCredits = await this.getAllCreditsForTeam(teamId);
|
||||
const teamCredits = await this._getAllCreditsForTeam({ teamId, tx });
|
||||
const remaningMonthlyCredits =
|
||||
teamCredits.totalRemainingMonthlyCredits > 0 ? teamCredits.totalRemainingMonthlyCredits : 0;
|
||||
return {
|
||||
@@ -180,12 +266,12 @@ export class CreditService {
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const team = await this.getTeamWithAvailableCredits(userId);
|
||||
const team = await this._getTeamWithAvailableCredits({ userId, tx });
|
||||
if (team) {
|
||||
return { ...team, remainingCredits: team.availableCredits - credits };
|
||||
}
|
||||
|
||||
const userCredits = await this.getAllCredits({ userId });
|
||||
const userCredits = await this._getAllCredits({ userId, tx });
|
||||
|
||||
return {
|
||||
userId,
|
||||
@@ -196,48 +282,58 @@ export class CreditService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createExpenseLog(props: {
|
||||
protected async _createExpenseLog(props: {
|
||||
bookingUid?: string;
|
||||
smsSid?: string;
|
||||
teamId?: number;
|
||||
userId?: number;
|
||||
credits: number | null;
|
||||
creditType: CreditType;
|
||||
tx: PrismaTransaction;
|
||||
}) {
|
||||
const { credits, creditType, bookingUid, smsSid, teamId, userId } = props;
|
||||
const { credits, creditType, bookingUid, smsSid, teamId, userId, tx } = props;
|
||||
let creditBalance: { id: string; additionalCredits: number } | null | undefined =
|
||||
await CreditsRepository.findCreditBalance({ teamId, userId });
|
||||
await CreditsRepository.findCreditBalance({ teamId, userId }, tx);
|
||||
|
||||
if (!creditBalance) {
|
||||
creditBalance = await CreditsRepository.createCreditBalance({
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
creditBalance = await CreditsRepository.createCreditBalance(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
if (credits && creditType === CreditType.ADDITIONAL) {
|
||||
const decrementValue =
|
||||
credits <= creditBalance.additionalCredits ? credits : creditBalance.additionalCredits;
|
||||
await CreditsRepository.updateCreditBalance({
|
||||
id: creditBalance.id,
|
||||
data: {
|
||||
additionalCredits: {
|
||||
decrement: decrementValue,
|
||||
await CreditsRepository.updateCreditBalance(
|
||||
{
|
||||
id: creditBalance.id,
|
||||
data: {
|
||||
additionalCredits: {
|
||||
decrement: decrementValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
if (creditBalance) {
|
||||
// also track logs with undefined credits (will be set on the cron job)
|
||||
await CreditsRepository.createCreditExpenseLog({
|
||||
creditBalanceId: creditBalance.id,
|
||||
credits,
|
||||
creditType,
|
||||
date: new Date(),
|
||||
bookingUid,
|
||||
smsSid,
|
||||
});
|
||||
await CreditsRepository.createCreditExpenseLog(
|
||||
{
|
||||
creditBalanceId: creditBalance.id,
|
||||
credits,
|
||||
creditType,
|
||||
date: new Date(),
|
||||
bookingUid,
|
||||
smsSid,
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,18 +344,20 @@ export class CreditService {
|
||||
- Sends limit reached email
|
||||
- cancels all already scheduled SMS (from the next two hours)
|
||||
*/
|
||||
async handleLowCreditBalance({
|
||||
protected async _handleLowCreditBalance({
|
||||
teamId,
|
||||
userId,
|
||||
remainingCredits,
|
||||
tx,
|
||||
}: {
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
remainingCredits: number;
|
||||
}) {
|
||||
tx: PrismaTransaction;
|
||||
}): Promise<LowCreditBalanceResult> {
|
||||
let warningLimit = 0;
|
||||
if (teamId) {
|
||||
const { totalMonthlyCredits } = await this.getAllCreditsForTeam(teamId);
|
||||
const { totalMonthlyCredits } = await this._getAllCreditsForTeam({ teamId, tx });
|
||||
warningLimit = totalMonthlyCredits * 0.2;
|
||||
} else if (userId) {
|
||||
const billingService = new StripeBillingService();
|
||||
@@ -269,13 +367,13 @@ export class CreditService {
|
||||
}
|
||||
|
||||
if (remainingCredits < warningLimit) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalanceWithTeamOrUser({ teamId, userId });
|
||||
const creditBalance = await CreditsRepository.findCreditBalanceWithTeamOrUser({ teamId, userId }, tx);
|
||||
|
||||
if (
|
||||
creditBalance?.limitReachedAt &&
|
||||
(!teamId || dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month")))
|
||||
) {
|
||||
return; // user has limit already reached or team has already reached limit this month
|
||||
return null; // user has limit already reached or team has already reached limit this month
|
||||
}
|
||||
|
||||
const teamWithAdmins = creditBalance?.team
|
||||
@@ -301,58 +399,122 @@ export class CreditService {
|
||||
|
||||
if ((!teamWithAdmins || !teamWithAdmins.adminAndOwners?.length) && !user) {
|
||||
log.error("Team or user not found to send warning email");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (remainingCredits <= 0) {
|
||||
await sendCreditBalanceLimitReachedEmails({
|
||||
await CreditsRepository.updateCreditBalance(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
data: {
|
||||
limitReachedAt: new Date(),
|
||||
warningSentAt: null,
|
||||
},
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
type: "LIMIT_REACHED" as const,
|
||||
team: teamWithAdmins,
|
||||
user,
|
||||
});
|
||||
|
||||
await CreditsRepository.updateCreditBalance({
|
||||
teamId,
|
||||
userId,
|
||||
data: {
|
||||
limitReachedAt: new Date(),
|
||||
warningSentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await cancelScheduledMessagesAndScheduleEmails({ teamId, userId });
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
creditBalance?.warningSentAt &&
|
||||
(!teamId || dayjs(creditBalance?.warningSentAt).isAfter(dayjs().startOf("month")))
|
||||
) {
|
||||
return; // user has already received a warning or team has already sent warning email this month
|
||||
return null; // user has already received a warning or team has already sent warning email this month
|
||||
}
|
||||
|
||||
await sendCreditBalanceLowWarningEmails({
|
||||
await CreditsRepository.updateCreditBalance(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
data: {
|
||||
warningSentAt: new Date(),
|
||||
},
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return {
|
||||
type: "WARNING" as const,
|
||||
balance: remainingCredits,
|
||||
team: teamWithAdmins,
|
||||
user,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
await CreditsRepository.updateCreditBalance({
|
||||
await CreditsRepository.updateCreditBalance(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
data: {
|
||||
warningSentAt: new Date(),
|
||||
warningSentAt: null,
|
||||
limitReachedAt: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await CreditsRepository.updateCreditBalance({
|
||||
teamId,
|
||||
userId,
|
||||
data: {
|
||||
warningSentAt: null,
|
||||
limitReachedAt: null,
|
||||
},
|
||||
});
|
||||
tx
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async _handleLowCreditBalanceResult(result: LowCreditBalanceResult) {
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
if (result.type === "LIMIT_REACHED") {
|
||||
await Promise.all([
|
||||
sendCreditBalanceLimitReachedEmails({
|
||||
team: result.team,
|
||||
user: result.user,
|
||||
}).catch((error) => {
|
||||
log.error("Failed to send credit limit reached email", error, { result });
|
||||
}),
|
||||
cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch(
|
||||
(error) => {
|
||||
log.error("Failed to cancel scheduled messages", error, { result });
|
||||
}
|
||||
),
|
||||
]);
|
||||
} else if (result.type === "WARNING") {
|
||||
await sendCreditBalanceLowWarningEmails({
|
||||
balance: result.balance,
|
||||
team: result.team,
|
||||
user: result.user,
|
||||
}).catch((error) => {
|
||||
log.error("Failed to send credit warning email", error, { result });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch any other unexpected errors
|
||||
log.error("Unexpected error handling low credit balance result", error, { result });
|
||||
}
|
||||
}
|
||||
|
||||
async handleLowCreditBalance({
|
||||
teamId,
|
||||
userId,
|
||||
remainingCredits,
|
||||
}: {
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
remainingCredits: number;
|
||||
}) {
|
||||
return prisma
|
||||
.$transaction(async (tx) => {
|
||||
const result = await this._handleLowCreditBalance({ teamId, userId, remainingCredits, tx });
|
||||
return result;
|
||||
})
|
||||
.then(async (result) => {
|
||||
await this._handleLowCreditBalanceResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
async getMonthlyCredits(teamId: number) {
|
||||
@@ -388,12 +550,26 @@ export class CreditService {
|
||||
}
|
||||
|
||||
async getAllCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return this._getAllCredits({ userId, teamId, tx });
|
||||
});
|
||||
}
|
||||
|
||||
protected async _getAllCredits({
|
||||
userId,
|
||||
teamId,
|
||||
tx,
|
||||
}: {
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
tx: PrismaTransaction;
|
||||
}) {
|
||||
if (teamId) {
|
||||
return this.getAllCreditsForTeam(teamId);
|
||||
return this._getAllCreditsForTeam({ teamId, tx });
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalance({ userId });
|
||||
const creditBalance = await CreditsRepository.findCreditBalance({ userId }, tx);
|
||||
|
||||
return {
|
||||
totalMonthlyCredits: 0,
|
||||
@@ -410,7 +586,13 @@ export class CreditService {
|
||||
}
|
||||
|
||||
async getAllCreditsForTeam(teamId: number) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId });
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return this._getAllCreditsForTeam({ teamId, tx });
|
||||
});
|
||||
}
|
||||
|
||||
protected async _getAllCreditsForTeam({ teamId, tx }: { teamId: number; tx: PrismaTransaction }) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId }, tx);
|
||||
|
||||
const totalMonthlyCredits = await this.getMonthlyCredits(teamId);
|
||||
const totalMonthlyCreditsUsed =
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
const MembersView = () => {
|
||||
const { t } = useLocale();
|
||||
const { data: currentOrg, isPending } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
|
||||
const isOrgAdminOrOwner = currentOrg && checkAdminOrOwner(currentOrg.user.role);
|
||||
|
||||
const canLoggedInUserSeeMembers =
|
||||
(currentOrg?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !currentOrg?.isPrivate;
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div>{!isPending && canLoggedInUserSeeMembers && <UserListTable />}</div>
|
||||
{!canLoggedInUserSeeMembers && (
|
||||
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
|
||||
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
|
||||
</div>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersView;
|
||||
@@ -1,29 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
|
||||
import NoPlatformPlan from "@calcom/web/components/settings/platform/dashboard/NoPlatformPlan";
|
||||
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
|
||||
import { PlatformPricing } from "@calcom/web/components/settings/platform/pricing/platform-pricing/index";
|
||||
|
||||
const PlatformMembersView = () => {
|
||||
const { t } = useLocale();
|
||||
const PlatformMembersView = (props: UserListTableProps) => {
|
||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } =
|
||||
useGetUserAttributes();
|
||||
const { data: currentOrg, isPending } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
|
||||
const currentOrg = props.org;
|
||||
const isOrgAdminOrOwner = currentOrg && checkAdminOrOwner(currentOrg.user.role);
|
||||
|
||||
const canLoggedInUserSeeMembers =
|
||||
(currentOrg?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !currentOrg?.isPrivate;
|
||||
|
||||
if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) {
|
||||
return <div className="m-5">Loading...</div>;
|
||||
return <UserListTableSkeleton />;
|
||||
}
|
||||
|
||||
if (isPlatformUser && !isPaidUser)
|
||||
@@ -38,37 +33,10 @@ const PlatformMembersView = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isPlatformUser)
|
||||
return (
|
||||
<div>
|
||||
<Shell isPlatformUser={true} withoutMain={false} SidebarContainer={<></>}>
|
||||
<NoPlatformPlan />
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
||||
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} />}>
|
||||
<div>{!isPending && canLoggedInUserSeeMembers && <UserListTable />}</div>
|
||||
</Shell>
|
||||
return !isPlatformUser ? (
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
|
||||
) : (
|
||||
<NoPlatformPlan />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { revalidateEventTypesList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/event-types/actions";
|
||||
|
||||
import { useOrgBranding } from "../../organizations/context/provider";
|
||||
import { subdomainSuffix } from "../../organizations/lib/orgDomains";
|
||||
@@ -42,6 +43,7 @@ export const CreateANewTeamForm = (props: CreateANewTeamFormProps) => {
|
||||
const createTeamMutation = trpc.viewer.teams.create.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
revalidateEventTypesList();
|
||||
onSuccess(data);
|
||||
},
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { revalidateEventTypesList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/event-types/actions";
|
||||
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
|
||||
|
||||
const regex = new RegExp("^[a-zA-Z0-9-]*$");
|
||||
@@ -126,6 +127,7 @@ const ProfileView = () => {
|
||||
revalidateTeamsList();
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
revalidateEventTypesList();
|
||||
await utils.viewer.eventTypes.getByViewer.invalidate();
|
||||
showToast(t("your_team_disbanded_successfully"), "success");
|
||||
router.push(`${WEBAPP_URL}/teams`);
|
||||
@@ -277,6 +279,7 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {
|
||||
});
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
revalidateEventTypesList();
|
||||
// TODO: Not all changes require list invalidation
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
revalidateTeamsList();
|
||||
|
||||
@@ -12,6 +12,10 @@ import { getWhatsappTemplateFunction, isAttendeeAction } from "../lib/actionHelp
|
||||
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
|
||||
import { select } from "../lib/getWorkflowReminders";
|
||||
import { scheduleSmsOrFallbackEmail } from "../lib/reminders/messageDispatcher";
|
||||
import {
|
||||
getContentSidForTemplate,
|
||||
getContentVariablesForTemplate,
|
||||
} from "../lib/reminders/templates/whatsapp/ContentSidMapping";
|
||||
|
||||
export async function handler(req: NextRequest) {
|
||||
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
|
||||
@@ -74,13 +78,29 @@ export async function handler(req: NextRequest) {
|
||||
? reminder.booking?.attendees[0].timeZone
|
||||
: reminder.booking?.user?.timeZone;
|
||||
|
||||
const startTime = reminder.booking?.startTime.toISOString();
|
||||
const locale = reminder.booking.user?.locale || "en";
|
||||
const timeFormat = getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat);
|
||||
|
||||
const templateFunction = getWhatsappTemplateFunction(reminder.workflowStep.template);
|
||||
const contentSid = getContentSidForTemplate(reminder.workflowStep.template);
|
||||
const contentVariables = getContentVariablesForTemplate({
|
||||
name: userName,
|
||||
attendeeName: attendeeName || "",
|
||||
eventName: reminder.booking?.eventType?.title,
|
||||
eventDate: dayjs(startTime).tz(timeZone).locale(locale).format("YYYY MMM D"),
|
||||
startTime: dayjs(startTime)
|
||||
.tz(timeZone)
|
||||
.locale(locale)
|
||||
.format(timeFormat || "h:mma"),
|
||||
timeZone,
|
||||
});
|
||||
const message = templateFunction(
|
||||
false,
|
||||
reminder.booking.user?.locale || "en",
|
||||
reminder.workflowStep.action,
|
||||
getTimeFormatStringFromUserTimeFormat(reminder.booking.user?.timeFormat),
|
||||
reminder.booking?.startTime.toISOString() || "",
|
||||
timeFormat,
|
||||
startTime || "",
|
||||
reminder.booking?.eventType?.title || "",
|
||||
timeZone || "",
|
||||
attendeeName || "",
|
||||
@@ -98,6 +118,8 @@ export async function handler(req: NextRequest) {
|
||||
userId,
|
||||
teamId,
|
||||
isWhatsapp: true,
|
||||
contentSid,
|
||||
contentVariables,
|
||||
},
|
||||
fallbackData:
|
||||
reminder.workflowStep.action && isAttendeeAction(reminder.workflowStep.action)
|
||||
|
||||
@@ -18,6 +18,8 @@ export async function sendSmsOrFallbackEmail(props: {
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
isWhatsapp?: boolean;
|
||||
contentSid?: string;
|
||||
contentVariables?: Record<string, string>;
|
||||
};
|
||||
fallbackData?: {
|
||||
email: string;
|
||||
@@ -62,6 +64,8 @@ export async function scheduleSmsOrFallbackEmail(props: {
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
isWhatsapp?: boolean;
|
||||
contentSid?: string;
|
||||
contentVariables?: Record<string, string>;
|
||||
};
|
||||
fallbackData?: {
|
||||
email: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ type EmailData = Omit<WorkflowEmailData, "to"> & {
|
||||
|
||||
export async function sendOrScheduleWorkflowEmails(mailData: EmailData) {
|
||||
if (mailData.sendAt) {
|
||||
if (mailData.sendAt <= new Date()) return;
|
||||
const { sendAt, referenceUid, ...taskerData } = mailData;
|
||||
return await tasker.create("sendWorkflowEmails", taskerData, {
|
||||
scheduledAt: sendAt,
|
||||
|
||||
@@ -40,6 +40,8 @@ export const sendSMS = async ({
|
||||
userId,
|
||||
teamId,
|
||||
isWhatsapp = false,
|
||||
contentSid,
|
||||
contentVariables,
|
||||
}: {
|
||||
phoneNumber: string;
|
||||
body: string;
|
||||
@@ -48,8 +50,10 @@ export const sendSMS = async ({
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
isWhatsapp?: boolean;
|
||||
contentSid?: string;
|
||||
contentVariables?: Record<string, string>;
|
||||
}) => {
|
||||
log.silly("sendSMS", JSON.stringify({ phoneNumber, body, sender, userId, teamId }));
|
||||
log.silly("sendSMS", JSON.stringify({ phoneNumber, body, sender, userId, teamId, contentSid }));
|
||||
|
||||
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
|
||||
|
||||
@@ -80,15 +84,32 @@ export const sendSMS = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const response = await twilio.messages.create({
|
||||
body: body,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
|
||||
to: getSMSNumber(phoneNumber, isWhatsapp),
|
||||
from: isWhatsapp ? getDefaultSender(isWhatsapp) : sender || getDefaultSender(),
|
||||
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
|
||||
});
|
||||
if (isWhatsapp) {
|
||||
const messageOptions: any = {
|
||||
contentSid: contentSid,
|
||||
to: getSMSNumber(phoneNumber, isWhatsapp),
|
||||
from: getDefaultSender(isWhatsapp),
|
||||
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
|
||||
};
|
||||
|
||||
return response;
|
||||
if (contentVariables) {
|
||||
messageOptions.contentVariables = JSON.stringify(contentVariables);
|
||||
}
|
||||
|
||||
const response = await twilio.messages.create(messageOptions);
|
||||
return response;
|
||||
} else {
|
||||
const response = await twilio.messages.create({
|
||||
body: body,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
|
||||
to: getSMSNumber(phoneNumber),
|
||||
from: sender || getDefaultSender(),
|
||||
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusCallbackUrl = (userId?: number | null, teamId?: number | null, bookingUid?: string | null) => {
|
||||
@@ -108,6 +129,8 @@ export const scheduleSMS = async ({
|
||||
userId,
|
||||
teamId,
|
||||
isWhatsapp = false,
|
||||
contentSid,
|
||||
contentVariables,
|
||||
}: {
|
||||
phoneNumber: string;
|
||||
body: string;
|
||||
@@ -117,6 +140,8 @@ export const scheduleSMS = async ({
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
isWhatsapp?: boolean;
|
||||
contentSid?: string;
|
||||
contentVariables?: Record<string, string>;
|
||||
}) => {
|
||||
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
|
||||
|
||||
@@ -145,17 +170,37 @@ export const scheduleSMS = async ({
|
||||
rateLimitingType: "smsMonth",
|
||||
});
|
||||
}
|
||||
const response = await twilio.messages.create({
|
||||
body,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
|
||||
to: getSMSNumber(phoneNumber, isWhatsapp),
|
||||
scheduleType: "fixed",
|
||||
sendAt: scheduledDate,
|
||||
from: isWhatsapp ? getDefaultSender(isWhatsapp) : sender || getDefaultSender(),
|
||||
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
|
||||
});
|
||||
|
||||
return response;
|
||||
if (isWhatsapp) {
|
||||
const messageOptions: any = {
|
||||
contentSid: contentSid,
|
||||
to: getSMSNumber(phoneNumber, isWhatsapp),
|
||||
scheduleType: "fixed",
|
||||
sendAt: scheduledDate,
|
||||
from: getDefaultSender(isWhatsapp),
|
||||
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
|
||||
};
|
||||
|
||||
if (contentVariables) {
|
||||
messageOptions.contentVariables = JSON.stringify(contentVariables);
|
||||
}
|
||||
|
||||
const response = await twilio.messages.create(messageOptions);
|
||||
return response;
|
||||
} else {
|
||||
const response = await twilio.messages.create({
|
||||
body,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
|
||||
to: getSMSNumber(phoneNumber),
|
||||
scheduleType: "fixed",
|
||||
sendAt: scheduledDate,
|
||||
from: sender || getDefaultSender(),
|
||||
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
export const cancelSMS = async (referenceId: string) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
|
||||
export const getContentSidForTemplate = (template?: WorkflowTemplates): string | undefined => {
|
||||
if (!template) {
|
||||
return process.env.TWILIO_WHATSAPP_REMINDER_CONTENT_SID;
|
||||
}
|
||||
switch (template) {
|
||||
case WorkflowTemplates.REMINDER:
|
||||
return process.env.TWILIO_WHATSAPP_REMINDER_CONTENT_SID;
|
||||
case WorkflowTemplates.CANCELLED:
|
||||
return process.env.TWILIO_WHATSAPP_CANCELLED_CONTENT_SID;
|
||||
case WorkflowTemplates.RESCHEDULED:
|
||||
return process.env.TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID;
|
||||
case WorkflowTemplates.COMPLETED:
|
||||
return process.env.TWILIO_WHATSAPP_COMPLETED_CONTENT_SID;
|
||||
default:
|
||||
return process.env.TWILIO_WHATSAPP_REMINDER_CONTENT_SID;
|
||||
}
|
||||
};
|
||||
|
||||
export const getContentVariablesForTemplate = (variables: {
|
||||
name?: string;
|
||||
attendeeName?: string;
|
||||
eventName?: string;
|
||||
eventDate?: string;
|
||||
startTime?: string;
|
||||
timeZone?: string;
|
||||
}): Record<string, string> => {
|
||||
const { name, attendeeName, eventName, eventDate, startTime, timeZone } = variables;
|
||||
|
||||
const commonVariables: Record<string, string> = {
|
||||
"1": name || "",
|
||||
"2": attendeeName || "",
|
||||
"3": eventName || "",
|
||||
"4": eventDate || "",
|
||||
"5": `${startTime ?? ""} ${timeZone ?? ""}`,
|
||||
};
|
||||
|
||||
return commonVariables;
|
||||
};
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
} from "@calcom/prisma/enums";
|
||||
|
||||
import { isAttendeeAction } from "../actionHelperFunctions";
|
||||
import {
|
||||
getContentSidForTemplate,
|
||||
getContentVariablesForTemplate,
|
||||
} from "../reminders/templates/whatsapp/ContentSidMapping";
|
||||
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
|
||||
import type { ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
|
||||
import { deleteScheduledSMSReminder } from "./smsReminderManager";
|
||||
@@ -76,7 +80,21 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
action === WorkflowActions.WHATSAPP_ATTENDEE ? evt.organizer.name : evt.attendees[0].name;
|
||||
const timeZone =
|
||||
action === WorkflowActions.WHATSAPP_ATTENDEE ? evt.attendees[0].timeZone : evt.organizer.timeZone;
|
||||
const locale = evt.organizer.language.locale;
|
||||
const timeFormat = evt.organizer.timeFormat;
|
||||
|
||||
const contentSid = getContentSidForTemplate(template);
|
||||
const contentVariables = getContentVariablesForTemplate({
|
||||
name,
|
||||
attendeeName,
|
||||
eventName: evt.title,
|
||||
eventDate: dayjs(startTime).tz(timeZone).locale(locale).format("YYYY MMM D"),
|
||||
startTime: dayjs(startTime)
|
||||
.tz(timeZone)
|
||||
.locale(locale)
|
||||
.format(timeFormat || "h:mma"),
|
||||
timeZone,
|
||||
});
|
||||
let textMessage = message;
|
||||
|
||||
switch (template) {
|
||||
@@ -84,9 +102,9 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
textMessage =
|
||||
whatsappReminderTemplate(
|
||||
false,
|
||||
evt.organizer.language.locale,
|
||||
locale,
|
||||
action,
|
||||
evt.organizer.timeFormat,
|
||||
timeFormat,
|
||||
evt.startTime,
|
||||
evt.title,
|
||||
timeZone,
|
||||
@@ -98,9 +116,9 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
textMessage =
|
||||
whatsappEventCancelledTemplate(
|
||||
false,
|
||||
evt.organizer.language.locale,
|
||||
locale,
|
||||
action,
|
||||
evt.organizer.timeFormat,
|
||||
timeFormat,
|
||||
evt.startTime,
|
||||
evt.title,
|
||||
timeZone,
|
||||
@@ -112,9 +130,9 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
textMessage =
|
||||
whatsappEventRescheduledTemplate(
|
||||
false,
|
||||
evt.organizer.language.locale,
|
||||
locale,
|
||||
action,
|
||||
evt.organizer.timeFormat,
|
||||
timeFormat,
|
||||
evt.startTime,
|
||||
evt.title,
|
||||
timeZone,
|
||||
@@ -126,9 +144,9 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
textMessage =
|
||||
whatsappEventCompletedTemplate(
|
||||
false,
|
||||
evt.organizer.language.locale,
|
||||
locale,
|
||||
action,
|
||||
evt.organizer.timeFormat,
|
||||
timeFormat,
|
||||
evt.startTime,
|
||||
evt.title,
|
||||
timeZone,
|
||||
@@ -140,9 +158,9 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
textMessage =
|
||||
whatsappReminderTemplate(
|
||||
false,
|
||||
evt.organizer.language.locale,
|
||||
locale,
|
||||
action,
|
||||
evt.organizer.timeFormat,
|
||||
timeFormat,
|
||||
evt.startTime,
|
||||
evt.title,
|
||||
timeZone,
|
||||
@@ -170,6 +188,8 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
userId,
|
||||
teamId,
|
||||
isWhatsapp: true,
|
||||
contentSid,
|
||||
contentVariables,
|
||||
},
|
||||
fallbackData: isAttendeeAction(action)
|
||||
? {
|
||||
@@ -203,6 +223,8 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
userId,
|
||||
teamId,
|
||||
isWhatsapp: true,
|
||||
contentSid,
|
||||
contentVariables,
|
||||
},
|
||||
fallbackData: isAttendeeAction(action)
|
||||
? {
|
||||
|
||||
@@ -96,8 +96,8 @@ const mockBookings = [
|
||||
eventTypeId: 1,
|
||||
userId: 101,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `2024-05-20T09:00:00.000Z`,
|
||||
endTime: `2024-05-20T09:15:00.000Z`,
|
||||
startTime: `2024-05-21T09:00:00.000Z`,
|
||||
endTime: `2024-05-21T09:15:00.000Z`,
|
||||
attendees: [{ email: "attendee@example.com", locale: "en" }],
|
||||
},
|
||||
{
|
||||
@@ -105,8 +105,8 @@ const mockBookings = [
|
||||
eventTypeId: 1,
|
||||
userId: 101,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `2024-05-20T09:15:00.000Z`,
|
||||
endTime: `2024-05-20T09:30:00.000Z`,
|
||||
startTime: `2024-05-21T09:15:00.000Z`,
|
||||
endTime: `2024-05-21T09:30:00.000Z`,
|
||||
attendees: [{ email: "attendee@example.com", locale: "en" }],
|
||||
},
|
||||
{
|
||||
@@ -518,8 +518,8 @@ describe("scheduleBookingReminders", () => {
|
||||
);
|
||||
|
||||
const expectedScheduledDates = [
|
||||
new Date("2024-05-20T08:00:00.000"),
|
||||
new Date("2024-05-20T08:15:00.000Z"),
|
||||
new Date("2024-05-21T08:00:00.000"),
|
||||
new Date("2024-05-21T08:15:00.000Z"),
|
||||
new Date("2024-06-01T03:30:00.000Z"),
|
||||
new Date("2024-06-02T03:30:00.000Z"),
|
||||
];
|
||||
@@ -604,8 +604,8 @@ describe("scheduleBookingReminders", () => {
|
||||
);
|
||||
|
||||
const expectedScheduledDates = [
|
||||
new Date("2024-05-20T10:15:00.000"),
|
||||
new Date("2024-05-20T10:30:00.000Z"),
|
||||
new Date("2024-05-21T10:15:00.000"),
|
||||
new Date("2024-05-21T10:30:00.000Z"),
|
||||
new Date("2024-06-01T06:00:00.000Z"),
|
||||
new Date("2024-06-02T06:00:00.000Z"),
|
||||
];
|
||||
@@ -642,11 +642,11 @@ describe("scheduleBookingReminders", () => {
|
||||
{
|
||||
name: "Workflow",
|
||||
userId: 101,
|
||||
trigger: "AFTER_EVENT",
|
||||
trigger: "BEFORE_EVENT",
|
||||
action: "SMS_NUMBER",
|
||||
template: "REMINDER",
|
||||
activeOn: [],
|
||||
time: 3,
|
||||
time: 20,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
sendTo: "000",
|
||||
},
|
||||
@@ -710,13 +710,13 @@ describe("scheduleBookingReminders", () => {
|
||||
expectSMSWorkflowToBeTriggered({
|
||||
sms,
|
||||
toNumber: "000",
|
||||
includedString: "2024 May 20 at 2:30pm Asia/Kolkata",
|
||||
includedString: "2024 May 21 at 2:30pm Asia/Kolkata",
|
||||
});
|
||||
|
||||
expectSMSWorkflowToBeTriggered({
|
||||
sms,
|
||||
toNumber: "000",
|
||||
includedString: "2024 May 20 at 2:45pm Asia/Kolkata",
|
||||
includedString: "2024 May 21 at 2:45pm Asia/Kolkata",
|
||||
});
|
||||
|
||||
// sms are too far in future
|
||||
@@ -744,10 +744,10 @@ describe("scheduleBookingReminders", () => {
|
||||
);
|
||||
|
||||
const expectedScheduledDates = [
|
||||
new Date("2024-05-20T12:15:00.000"),
|
||||
new Date("2024-05-20T12:30:00.000Z"),
|
||||
new Date("2024-06-01T08:00:00.000Z"),
|
||||
new Date("2024-06-02T08:00:00.000Z"),
|
||||
new Date("2024-05-20T13:00:00.000"),
|
||||
new Date("2024-05-20T13:15:00.000Z"),
|
||||
new Date("2024-05-31T08:30:00.000Z"),
|
||||
new Date("2024-06-01T08:30:00.000Z"),
|
||||
];
|
||||
|
||||
scheduledWorkflowReminders.forEach((reminder, index) => {
|
||||
@@ -760,6 +760,81 @@ describe("scheduleBookingReminders", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("should not schedule reminders if date is already in the past", async ({}) => {
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
defaultScheduleId: null,
|
||||
schedules: [TestData.schedules.IstMorningShift],
|
||||
});
|
||||
|
||||
const pastBooking = {
|
||||
uid: "past-booking-uid",
|
||||
eventTypeId: 1,
|
||||
userId: 101,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `2024-05-21T09:00:00.000Z`,
|
||||
endTime: `2024-05-21T09:15:00.000Z`,
|
||||
attendees: [{ email: "attendee@example.com", locale: "en" }],
|
||||
};
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
workflows: [
|
||||
{
|
||||
name: "Past Workflow",
|
||||
userId: 101,
|
||||
trigger: "BEFORE_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeOn: [],
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.DAY,
|
||||
},
|
||||
],
|
||||
eventTypes: mockEventTypes,
|
||||
bookings: [pastBooking],
|
||||
organizer,
|
||||
})
|
||||
);
|
||||
|
||||
const workflow = await prismock.workflow.findFirst({
|
||||
select: workflowSelect,
|
||||
});
|
||||
|
||||
const bookings = await prismock.booking.findMany({
|
||||
where: {
|
||||
userId: organizer.id,
|
||||
},
|
||||
select: bookingSelect,
|
||||
});
|
||||
|
||||
expect(workflow).not.toBeNull();
|
||||
|
||||
if (!workflow) return;
|
||||
|
||||
await scheduleBookingReminders(
|
||||
bookings,
|
||||
workflow.steps,
|
||||
workflow.time,
|
||||
workflow.timeUnit,
|
||||
workflow.trigger,
|
||||
organizer.id,
|
||||
null, //teamId
|
||||
false //isOrg
|
||||
);
|
||||
|
||||
const tasks = await prismock.task.findMany({
|
||||
where: {
|
||||
type: "sendWorkflowEmails",
|
||||
},
|
||||
});
|
||||
|
||||
// No tasks should be created for past reminders
|
||||
expect(tasks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWorkfowRemindersOfRemovedMember", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Editor } from "@calcom/ui/components/editor";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateEventTypesList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/event-types/actions";
|
||||
|
||||
const querySchema = z.object({
|
||||
title: z.string().min(1),
|
||||
@@ -74,6 +75,7 @@ const DuplicateDialog = () => {
|
||||
await router.replace(`/event-types/${eventType.id}`);
|
||||
|
||||
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
|
||||
revalidateEventTypesList();
|
||||
await utils.viewer.eventTypes.getEventTypesFromGroup.invalidate({
|
||||
limit: 10,
|
||||
searchQuery: debouncedSearchTerm,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
|
||||
import kysely from "@calcom/kysely";
|
||||
import db from "@calcom/prisma";
|
||||
|
||||
import type { AppFlags } from "./config";
|
||||
@@ -58,6 +59,27 @@ export class FeaturesRepository implements IFeaturesRepository {
|
||||
}, {} as AppFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all features enabled for a specific team in a map format.
|
||||
* @param teamId - The ID of the team to get features for
|
||||
* @returns Promise<{ [slug: string]: boolean } | null>
|
||||
*/
|
||||
public async getTeamFeatures(teamId: number) {
|
||||
const result = await kysely
|
||||
.selectFrom("TeamFeatures")
|
||||
.innerJoin("Feature", "Feature.slug", "TeamFeatures.featureId")
|
||||
.select(["Feature.slug", "Feature.enabled"])
|
||||
.where("TeamFeatures.teamId", "=", teamId)
|
||||
.execute();
|
||||
|
||||
if (!result.length) return null;
|
||||
|
||||
return result.reduce<Record<keyof AppFlags, boolean>>((acc, feature) => {
|
||||
acc[feature.slug as keyof AppFlags] = true;
|
||||
return acc;
|
||||
}, {} as Record<keyof AppFlags, boolean>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature is enabled globally in the application.
|
||||
* @param slug - The feature flag identifier to check
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AppFlags } from "../config";
|
||||
|
||||
type TeamFeatures = Record<number, Record<keyof AppFlags, boolean>> | null;
|
||||
|
||||
export const useIsFeatureEnabledForTeam = ({
|
||||
teamFeatures,
|
||||
teamId,
|
||||
feature,
|
||||
}: {
|
||||
teamFeatures?: TeamFeatures;
|
||||
teamId?: number;
|
||||
feature: keyof AppFlags;
|
||||
}) => {
|
||||
if (!teamId || !teamFeatures?.[teamId]) return false;
|
||||
return teamFeatures[teamId][feature];
|
||||
};
|
||||
@@ -9,6 +9,7 @@ describe("BookingDenormalized", () => {
|
||||
let bookingId: number;
|
||||
const randomId = Math.floor(Math.random() * 1000000);
|
||||
const email = `booking-denorm-${randomId}@example.com`;
|
||||
const updatedEmail = `updated-${email}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
@@ -101,7 +102,7 @@ describe("BookingDenormalized", () => {
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: "Updated User",
|
||||
email: "updated@example.com",
|
||||
email: updatedEmail,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,7 +111,7 @@ describe("BookingDenormalized", () => {
|
||||
});
|
||||
|
||||
expect(userUpdatedDenormalizedBooking.userName).toBe("Updated User");
|
||||
expect(userUpdatedDenormalizedBooking.userEmail).toBe("updated@example.com");
|
||||
expect(userUpdatedDenormalizedBooking.userEmail).toBe(updatedEmail);
|
||||
});
|
||||
|
||||
it("should update denormalized entry when event type is updated", async () => {
|
||||
|
||||
+7
-6
@@ -12,14 +12,15 @@ describe("RoutingFormResponseDenormalized", () => {
|
||||
let bookingId: number;
|
||||
let responseId: number;
|
||||
const randomId = Math.floor(Math.random() * 1000000);
|
||||
const email = `booking-denorm-${randomId}@example.com`;
|
||||
const email = `routing-form-response-denorm-${randomId}@example.com`;
|
||||
const updatedEmail = `updated-${email}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username: `booking-denorm-testuser-${randomId}`,
|
||||
username: `routing-form-response-denorm-testuser-${randomId}`,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
@@ -96,7 +97,7 @@ describe("RoutingFormResponseDenormalized", () => {
|
||||
// Create test booking
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-denorm-${randomId}`,
|
||||
uid: `routing-form-response-denorm-${randomId}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
@@ -254,7 +255,7 @@ describe("RoutingFormResponseDenormalized", () => {
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: "Updated User",
|
||||
email: "updated@example.com",
|
||||
email: updatedEmail,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -263,7 +264,7 @@ describe("RoutingFormResponseDenormalized", () => {
|
||||
});
|
||||
|
||||
expect(denormalizedResponse.bookingUserName).toBe("Updated User");
|
||||
expect(denormalizedResponse.bookingUserEmail).toBe("updated@example.com");
|
||||
expect(denormalizedResponse.bookingUserEmail).toBe(updatedEmail);
|
||||
});
|
||||
|
||||
it("should update denormalized entry when booking is updated", async () => {
|
||||
@@ -636,7 +637,7 @@ describe("RoutingFormResponseDenormalized", () => {
|
||||
// Create a booking with attendees
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-denorm-${Math.floor(Math.random() * 1000000)}`,
|
||||
uid: `routing-form-response-denorm-${Math.floor(Math.random() * 1000000)}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
|
||||
@@ -63,7 +63,7 @@ function buildSqlCondition(condition: any): string {
|
||||
|
||||
class EventsInsights {
|
||||
static countGroupedByStatusForRanges = async (
|
||||
whereConditional: Prisma.BookingTimeStatusWhereInput,
|
||||
whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput,
|
||||
startDate: Dayjs,
|
||||
endDate: Dayjs,
|
||||
timeView: "week" | "month" | "year" | "day"
|
||||
@@ -95,9 +95,9 @@ class EventsInsights {
|
||||
"timeStatus",
|
||||
"noShowHost"
|
||||
FROM
|
||||
"BookingTimeStatus"
|
||||
"BookingTimeStatusDenormalized"
|
||||
JOIN
|
||||
"Attendee" "a" ON "a"."bookingId" = "BookingTimeStatus"."id"
|
||||
"Attendee" "a" ON "a"."bookingId" = "BookingTimeStatusDenormalized"."id"
|
||||
WHERE
|
||||
"createdAt" BETWEEN ${formattedStartDate}::timestamp AND ${formattedEndDate}::timestamp
|
||||
AND ${Prisma.raw(whereClause)}
|
||||
@@ -185,8 +185,8 @@ class EventsInsights {
|
||||
return aggregate;
|
||||
};
|
||||
|
||||
static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusWhereInput) => {
|
||||
const bookings = await prisma.bookingTimeStatus.findMany({
|
||||
static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
||||
const bookings = await prisma.bookingTimeStatusDenormalized.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
@@ -206,8 +206,8 @@ class EventsInsights {
|
||||
return totalNoShowGuests;
|
||||
};
|
||||
|
||||
static countGroupedByStatus = async (where: Prisma.BookingTimeStatusWhereInput) => {
|
||||
const data = await prisma.bookingTimeStatus.groupBy({
|
||||
static countGroupedByStatus = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
||||
const data = await prisma.bookingTimeStatusDenormalized.groupBy({
|
||||
where,
|
||||
by: ["timeStatus", "noShowHost"],
|
||||
_count: {
|
||||
@@ -237,8 +237,8 @@ class EventsInsights {
|
||||
);
|
||||
};
|
||||
|
||||
static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => {
|
||||
return await prisma.bookingTimeStatus.aggregate({
|
||||
static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
||||
return await prisma.bookingTimeStatusDenormalized.aggregate({
|
||||
_avg: {
|
||||
rating: true,
|
||||
},
|
||||
@@ -251,8 +251,8 @@ class EventsInsights {
|
||||
});
|
||||
};
|
||||
|
||||
static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => {
|
||||
const result = await prisma.bookingTimeStatus.findMany({
|
||||
static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => {
|
||||
const result = await prisma.bookingTimeStatusDenormalized.findMany({
|
||||
where: {
|
||||
...whereConditional,
|
||||
rating: {
|
||||
@@ -385,11 +385,11 @@ class EventsInsights {
|
||||
const limit = props.limit ?? 100; // Default batch size
|
||||
const offset = props.offset ?? 0;
|
||||
|
||||
const totalCountPromise = prisma.bookingTimeStatus.count({
|
||||
const totalCountPromise = prisma.bookingTimeStatusDenormalized.count({
|
||||
where: whereConditional,
|
||||
});
|
||||
|
||||
const csvDataPromise = prisma.bookingTimeStatus.findMany({
|
||||
const csvDataPromise = prisma.bookingTimeStatusDenormalized.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
@@ -402,7 +402,7 @@ class EventsInsights {
|
||||
endTime: true,
|
||||
paid: true,
|
||||
userEmail: true,
|
||||
username: true,
|
||||
userUsername: true,
|
||||
rating: true,
|
||||
ratingFeedback: true,
|
||||
noShowHost: true,
|
||||
@@ -496,7 +496,7 @@ class EventsInsights {
|
||||
} = props;
|
||||
|
||||
// Obtain the where conditional
|
||||
let whereConditional: Prisma.BookingTimeStatusWhereInput = {};
|
||||
let whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {};
|
||||
|
||||
if (startDate && endDate) {
|
||||
whereConditional.createdAt = {
|
||||
|
||||
@@ -49,9 +49,9 @@ export const buildBaseWhereCondition = async ({
|
||||
isAll,
|
||||
ctx,
|
||||
}: BuildBaseWhereConditionType): Promise<{
|
||||
whereCondition: Prisma.BookingTimeStatusWhereInput;
|
||||
whereCondition: Prisma.BookingTimeStatusDenormalizedWhereInput;
|
||||
}> => {
|
||||
const conditions: Prisma.BookingTimeStatusWhereInput[] = [];
|
||||
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
|
||||
|
||||
// EventType Filter
|
||||
if (eventTypeId) {
|
||||
@@ -152,7 +152,7 @@ export const buildBaseWhereCondition = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let whereCondition: Prisma.BookingTimeStatusWhereInput = {};
|
||||
let whereCondition: Prisma.BookingTimeStatusDenormalizedWhereInput = {};
|
||||
|
||||
if (conditions.length === 1) {
|
||||
whereCondition = conditions[0];
|
||||
@@ -597,7 +597,7 @@ export const insightsRouter = router({
|
||||
},
|
||||
};
|
||||
|
||||
const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["eventTypeId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -734,7 +734,7 @@ export const insightsRouter = router({
|
||||
|
||||
const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
|
||||
|
||||
const allBookings = await ctx.insightsDb.bookingTimeStatus.findMany({
|
||||
const allBookings = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({
|
||||
select: {
|
||||
eventLength: true,
|
||||
createdAt: true,
|
||||
@@ -804,7 +804,7 @@ export const insightsRouter = router({
|
||||
status: "CANCELLED",
|
||||
};
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -886,7 +886,7 @@ export const insightsRouter = router({
|
||||
},
|
||||
};
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -967,7 +967,7 @@ export const insightsRouter = router({
|
||||
},
|
||||
};
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -1262,7 +1262,7 @@ export const insightsRouter = router({
|
||||
];
|
||||
}
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.findMany({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({
|
||||
where: bookingWhere,
|
||||
orderBy: {
|
||||
endTime: "desc",
|
||||
@@ -1339,7 +1339,7 @@ export const insightsRouter = router({
|
||||
noShowHost: true,
|
||||
};
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -1417,7 +1417,7 @@ export const insightsRouter = router({
|
||||
rating: { not: null },
|
||||
};
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_avg: {
|
||||
@@ -1495,7 +1495,7 @@ export const insightsRouter = router({
|
||||
rating: { not: null },
|
||||
};
|
||||
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_avg: {
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
# Permission-Based Access Control (PBAC) System
|
||||
|
||||
## Overview
|
||||
The PBAC system provides fine-grained access control for Cal.com using a combination of CRUD-based permissions and custom actions, while maintaining backward compatibility with the existing role system.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Permission Store
|
||||
The system uses a centralized Zustand store with optimized data structures for fast permission lookups:
|
||||
|
||||
```typescript
|
||||
interface TeamPermissions {
|
||||
roleId: string;
|
||||
permissions: Set<PermissionString>; // O(1) lookup
|
||||
}
|
||||
|
||||
interface PermissionStore {
|
||||
teamPermissions: Map<number, TeamPermissions>; // O(1) lookup
|
||||
setTeamPermissions: (permissions: Record<number, { roleId: string; permissions: PermissionString[] }>) => void;
|
||||
hasPermission: (teamId: number, permission: PermissionString) => boolean;
|
||||
hasPermissions: (teamId: number, permissions: PermissionString[]) => boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Context Provider
|
||||
The system uses a React context provider that fetches all permissions once at the root level:
|
||||
|
||||
```tsx
|
||||
<PermissionProvider>
|
||||
<App />
|
||||
</PermissionProvider>
|
||||
```
|
||||
|
||||
The provider automatically:
|
||||
- Fetches permissions for all teams the user has access to
|
||||
- Caches the results (5-minute stale time)
|
||||
- Updates the central store
|
||||
- Provides loading states
|
||||
|
||||
### Permission Hooks
|
||||
Two main hooks are provided for checking permissions:
|
||||
|
||||
```typescript
|
||||
// Check single permission
|
||||
const { hasPermission, isLoading } = usePermission(teamId, "team.update");
|
||||
|
||||
// Check multiple permissions
|
||||
const { hasPermissions, isLoading } = usePermissions(teamId, ["team.update", "team.invite"]);
|
||||
```
|
||||
|
||||
## Permission Format
|
||||
Permissions follow two formats:
|
||||
1. CRUD Permissions: `${resource}.${action}`
|
||||
2. Custom Actions: `custom:${resource}.${action}`
|
||||
|
||||
```typescript
|
||||
// CRUD Permission Examples
|
||||
"eventType.create"
|
||||
"booking.read"
|
||||
|
||||
// Custom Action Examples
|
||||
"custom:team.invite"
|
||||
"custom:booking.readRecordings"
|
||||
```
|
||||
|
||||
## Role Types
|
||||
1. **Default Roles** (MembershipRole)
|
||||
- OWNER: Full access (`*.*`)
|
||||
- ADMIN: Extensive management permissions
|
||||
- MEMBER: Basic read permissions
|
||||
|
||||
2. **Custom Roles**
|
||||
- Team-specific roles with granular permissions
|
||||
- Can be assigned alongside default roles
|
||||
|
||||
## Permission Structure
|
||||
|
||||
### CRUD Actions
|
||||
```typescript
|
||||
export enum CrudAction {
|
||||
Create = 'create',
|
||||
Read = 'read',
|
||||
Update = 'update',
|
||||
Delete = 'delete',
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Actions
|
||||
```typescript
|
||||
export enum CustomAction {
|
||||
Manage = 'manage', // Full control over a resource
|
||||
Invite = 'invite', // Invite members to team/org
|
||||
Remove = 'remove', // Remove members from team/org
|
||||
Override = 'override', // Override availability
|
||||
ReadRecordings = 'readRecordings', // Access booking recordings
|
||||
ManageBilling = 'manageBilling', // Manage org billing
|
||||
}
|
||||
```
|
||||
|
||||
### Resources
|
||||
```typescript
|
||||
export enum Resource {
|
||||
EventType = 'eventType',
|
||||
Booking = 'booking',
|
||||
Team = 'team',
|
||||
Organization = 'organization',
|
||||
Insights = 'insights',
|
||||
Availability = 'availability',
|
||||
Workflow = 'workflow',
|
||||
RoutingForm = 'routingForm',
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### 1. Setup Provider
|
||||
```tsx
|
||||
// app/layout.tsx or similar
|
||||
import { PermissionProvider } from "@calcom/features/pbac/context/PermissionProvider";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PermissionProvider>
|
||||
{children}
|
||||
</PermissionProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use in Components
|
||||
```tsx
|
||||
import { usePermission, usePermissions } from "@calcom/features/pbac/hooks/usePermission";
|
||||
|
||||
function TeamSettings({ teamId }: { teamId: number }) {
|
||||
// Single permission check
|
||||
const { hasPermission, isLoading } = usePermission(teamId, "team.update");
|
||||
|
||||
// Multiple permissions check
|
||||
const { hasPermissions } = usePermissions(teamId, [
|
||||
"team.update",
|
||||
"team.invite"
|
||||
]);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasPermission && <button>Update Team</button>}
|
||||
{hasPermissions && <button>Update and Invite</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Performance Considerations
|
||||
- Permission checks are O(1) using Map and Set data structures
|
||||
- Permissions are fetched once and cached for 5 minutes
|
||||
- No redundant API calls for permission checks
|
||||
- Automatic updates when permissions change via store
|
||||
|
||||
## Common Permission Combinations
|
||||
|
||||
### Event Manager Role
|
||||
```typescript
|
||||
const permissions = [
|
||||
// CRUD Permissions
|
||||
"eventType.create",
|
||||
"eventType.read",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
|
||||
// Custom Actions
|
||||
"custom:booking.readRecordings",
|
||||
"custom:availability.override"
|
||||
];
|
||||
```
|
||||
|
||||
### Analytics Role
|
||||
```typescript
|
||||
const permissions = [
|
||||
// CRUD Permissions
|
||||
"insights.read",
|
||||
"booking.read",
|
||||
"eventType.read",
|
||||
|
||||
// Custom Actions
|
||||
"custom:organization.manageBilling"
|
||||
];
|
||||
```
|
||||
|
||||
### Team Admin Role
|
||||
```typescript
|
||||
const permissions = [
|
||||
// CRUD Permissions
|
||||
"team.create",
|
||||
"team.read",
|
||||
"team.update",
|
||||
|
||||
// Custom Actions
|
||||
"custom:team.invite",
|
||||
"custom:team.remove",
|
||||
"custom:availability.override"
|
||||
];
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
```prisma
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
teamId Int?
|
||||
permissions RolePermission[]
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
id String @id @default(cuid())
|
||||
roleId String
|
||||
resource String
|
||||
action String
|
||||
isCustom Boolean @default(false) // Indicates if this is a custom action
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
}
|
||||
```
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### 1. Event Type Management
|
||||
```typescript
|
||||
// Full event type management
|
||||
[
|
||||
"eventType.create",
|
||||
"eventType.read",
|
||||
"eventType.update",
|
||||
"eventType.delete"
|
||||
]
|
||||
|
||||
// Read-only access
|
||||
["eventType.read"]
|
||||
```
|
||||
|
||||
### 2. Team Management with Custom Actions
|
||||
```typescript
|
||||
[
|
||||
"team.read",
|
||||
"team.update",
|
||||
"custom:team.invite",
|
||||
"custom:team.remove"
|
||||
]
|
||||
```
|
||||
|
||||
### 3. Booking Management with Recordings
|
||||
```typescript
|
||||
[
|
||||
"booking.read",
|
||||
"booking.update",
|
||||
"custom:booking.readRecordings"
|
||||
]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Server-Side (React Server Components)
|
||||
```typescript
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { checkUserPermissionInTeam } from "@calcom/features/pbac/lib/server/checkPermissions";
|
||||
|
||||
// In a Server Component
|
||||
export default async function TeamSettings({ params }: { params: { teamId: string } }) {
|
||||
const session = buildLegacyRequest(await headers(), await cookies())
|
||||
|
||||
if(!session?.user?.id){
|
||||
return null
|
||||
}
|
||||
|
||||
const hasPermission = await checkUserPermissionInTeam({
|
||||
userId: session.user.id,
|
||||
teamId: parseInt(params.teamId),
|
||||
permission: "team.update",
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
return <div>Not authorized</div>;
|
||||
}
|
||||
|
||||
return <div>Team Settings</div>;
|
||||
}
|
||||
|
||||
// Check multiple permissions
|
||||
const hasPermissions = await checkMultiplePermissionsInTeam({
|
||||
userId: session.user.id,
|
||||
teamId: teamId,
|
||||
permissions: ["team.update", "team.invite"],
|
||||
});
|
||||
```
|
||||
|
||||
### Client-Side (React Components)
|
||||
```typescript
|
||||
import { usePermission, usePermissions } from "@calcom/features/pbac/hooks/usePermission";
|
||||
|
||||
// In a React Component
|
||||
function TeamSettingsButton({ teamId }: { teamId: number }) {
|
||||
// Single permission check
|
||||
const { hasPermission, isLoading } = usePermission(teamId, "team.update");
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!hasPermission) return null;
|
||||
|
||||
return <button>Update Team Settings</button>;
|
||||
}
|
||||
|
||||
// Multiple permissions check
|
||||
function TeamAdminPanel({ teamId }: { teamId: number }) {
|
||||
const { hasPermissions, isLoading } = usePermissions(teamId, [
|
||||
"team.update",
|
||||
"team.invite"
|
||||
]);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!hasPermissions) return <div>Insufficient permissions</div>;
|
||||
|
||||
return <div>Admin Panel</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Available Permissions
|
||||
|
||||
Permissions follow the format `resource.action` where:
|
||||
- `resource` is the entity being accessed (e.g., team, eventType, booking)
|
||||
- `action` is the operation being performed (e.g., create, read, update, delete)
|
||||
|
||||
Common permissions include:
|
||||
- `team.create` - Create teams
|
||||
- `team.update` - Update team settings
|
||||
- `team.invite` - Invite team members
|
||||
- `team.remove` - Remove team members
|
||||
- `eventType.create` - Create event types
|
||||
- `eventType.update` - Update event types
|
||||
- `booking.read` - Read booking details
|
||||
|
||||
For a complete list of permissions, see [PERMISSIONS.md](./PERMISSIONS.md).
|
||||
|
||||
### Caching
|
||||
|
||||
The client-side hooks automatically cache permission results for 5 minutes to reduce API calls. The cache can be invalidated by calling the TRPC mutation to update permissions.
|
||||
|
||||
## Default Roles and Permissions
|
||||
|
||||
The system comes with three pre-configured default roles:
|
||||
|
||||
### 1. Owner Role (owner_role)
|
||||
- Has full access to all resources via wildcard permission (`*.*`)
|
||||
- Automatically assigned to team/organization creators
|
||||
- Cannot be modified or deleted
|
||||
- Permissions:
|
||||
```typescript
|
||||
"*.*" // Grants access to all actions on all resources
|
||||
```
|
||||
|
||||
### 2. Admin Role (admin_role)
|
||||
- Has extensive management permissions
|
||||
- Can manage team settings and members
|
||||
- Permissions:
|
||||
```typescript
|
||||
// Booking permissions
|
||||
"booking.*" // All booking operations
|
||||
"booking.readTeamBookings"
|
||||
"booking.readOrgBookings"
|
||||
|
||||
// Event Type permissions
|
||||
"eventType.*" // All event type operations
|
||||
|
||||
// Team management
|
||||
"team.invite"
|
||||
"team.remove"
|
||||
"team.changeMemberRole"
|
||||
|
||||
// Organization permissions
|
||||
"organization.listMembers"
|
||||
"organization.read"
|
||||
"organization.update"
|
||||
|
||||
// Other resource permissions
|
||||
"apiKey.*" // All API key operations
|
||||
"routingForm.*" // All routing form operations
|
||||
"workflow.*" // All workflow operations
|
||||
"insights.read" // Read access to insights
|
||||
```
|
||||
|
||||
### 3. Member Role (member_role)
|
||||
- Basic read access to resources
|
||||
- Default role for new team members
|
||||
- Permissions:
|
||||
```typescript
|
||||
"booking.read"
|
||||
"eventType.read"
|
||||
"team.read"
|
||||
"organization.read"
|
||||
"routingForm.read"
|
||||
```
|
||||
|
||||
### Using Default Roles
|
||||
|
||||
You can reference these roles programmatically using the constants provided in `@calcom/features/pbac/lib/constants`:
|
||||
|
||||
```typescript
|
||||
import { DEFAULT_ROLES } from "@calcom/features/pbac/lib/constants";
|
||||
|
||||
// Reference roles
|
||||
const ownerRoleId = DEFAULT_ROLES.OWNER; // 'owner_role'
|
||||
const adminRoleId = DEFAULT_ROLES.ADMIN; // 'admin_role'
|
||||
const memberRoleId = DEFAULT_ROLES.MEMBER; // 'member_role'
|
||||
```
|
||||
|
||||
### Role Assignment
|
||||
|
||||
1. Default roles are automatically assigned during:
|
||||
- Team creation (creator gets OWNER role)
|
||||
- Member invitation (gets MEMBER role by default)
|
||||
- Role changes through team management UI
|
||||
|
||||
2. Roles can be changed by team owners and admins through:
|
||||
- Team member management interface
|
||||
- Organization member management interface
|
||||
- API endpoints (with proper permissions)
|
||||
@@ -0,0 +1,20 @@
|
||||
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
||||
// const { data: permissions, isLoading } = trpc.viewer.pbac.getUserPermissions.useQuery(undefined, {
|
||||
// // Since permissions don't change often, we can cache them for a while
|
||||
// staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
// });
|
||||
|
||||
// const setTeamPermissions = usePermissionStore((state) => state.setTeamPermissions);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (permissions) {
|
||||
// setTeamPermissions(permissions);
|
||||
// }
|
||||
// }, [permissions, setTeamPermissions]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { usePermissionStore } from "../store/permission-store";
|
||||
import type { PermissionString } from "../types/permission-registry";
|
||||
|
||||
export function usePermission(teamId: number, permission: PermissionString) {
|
||||
const hasPermission = usePermissionStore((state) => state.hasPermission(teamId, permission));
|
||||
const teamPermissions = usePermissionStore((state) => state.teamPermissions);
|
||||
const isLoading = !teamPermissions.has(teamId);
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePermissions(teamId: number, permissions: PermissionString[]) {
|
||||
const hasPermissions = usePermissionStore((state) => state.hasPermissions(teamId, permissions));
|
||||
const teamPermissions = usePermissionStore((state) => state.teamPermissions);
|
||||
const isLoading = !teamPermissions.has(teamId);
|
||||
|
||||
return {
|
||||
hasPermissions,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Default role IDs used in the PBAC system
|
||||
* These IDs match the ones created in the migration
|
||||
*/
|
||||
export const DEFAULT_ROLES = {
|
||||
OWNER: "owner_role",
|
||||
ADMIN: "admin_role",
|
||||
MEMBER: "member_role",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type for default role IDs
|
||||
*/
|
||||
export type DefaultRoleId = (typeof DEFAULT_ROLES)[keyof typeof DEFAULT_ROLES];
|
||||
|
||||
/**
|
||||
* Mapping of default roles to their descriptions
|
||||
*/
|
||||
export const DEFAULT_ROLE_DESCRIPTIONS = {
|
||||
[DEFAULT_ROLES.OWNER]: "pbac_owner_role_description",
|
||||
[DEFAULT_ROLES.ADMIN]: "pbac_admin_role_description",
|
||||
[DEFAULT_ROLES.MEMBER]: "pbac_member_role_description",
|
||||
} as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PermissionCheckService } from "../../services/permission-check.service";
|
||||
import type { PermissionString } from "../../types/permission-registry";
|
||||
|
||||
export async function checkUserPermissionInTeam({
|
||||
userId,
|
||||
teamId,
|
||||
permission,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
permission: PermissionString;
|
||||
}) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
return permissionCheckService.hasPermission({ userId, teamId }, permission);
|
||||
}
|
||||
|
||||
export async function checkMultiplePermissionsInTeam({
|
||||
userId,
|
||||
teamId,
|
||||
permissions,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
permissions: PermissionString[];
|
||||
}) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
return permissionCheckService.hasPermissions({ userId, teamId }, permissions);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { jsonObjectFrom, jsonArrayFrom } from "kysely/helpers/postgres";
|
||||
|
||||
import kysely from "@calcom/kysely";
|
||||
|
||||
import type { PermissionString } from "../types/permission-registry";
|
||||
|
||||
type DbPermission = {
|
||||
resource: string | null;
|
||||
action: string | null;
|
||||
};
|
||||
|
||||
type DbRole = {
|
||||
id: string | null;
|
||||
permissions: DbPermission[];
|
||||
};
|
||||
|
||||
type DbMembership = {
|
||||
teamId: number;
|
||||
role: DbRole | null;
|
||||
};
|
||||
|
||||
export class PermissionRepository {
|
||||
async getMembershipByMembershipId(membershipId: number) {
|
||||
return kysely
|
||||
.selectFrom("Membership")
|
||||
.leftJoin("Team", "Team.id", "Membership.teamId")
|
||||
.select([
|
||||
"Membership.id",
|
||||
"Membership.teamId",
|
||||
"Membership.userId",
|
||||
"Membership.customRoleId",
|
||||
"Team.parentId as team_parentId",
|
||||
])
|
||||
.where("Membership.id", "=", membershipId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getMembershipByUserAndTeam(userId: number, teamId: number) {
|
||||
return kysely
|
||||
.selectFrom("Membership")
|
||||
.leftJoin("Team", "Team.id", "Membership.teamId")
|
||||
.select([
|
||||
"Membership.id",
|
||||
"Membership.teamId",
|
||||
"Membership.userId",
|
||||
"Membership.customRoleId",
|
||||
"Team.parentId as team_parentId",
|
||||
])
|
||||
.where("Membership.userId", "=", userId)
|
||||
.where("Membership.teamId", "=", teamId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getOrgMembership(userId: number, parentId: number) {
|
||||
return kysely
|
||||
.selectFrom("Membership")
|
||||
.select(["Membership.id", "Membership.teamId", "Membership.userId", "Membership.customRoleId"])
|
||||
.where("Membership.userId", "=", userId)
|
||||
.where("Membership.teamId", "=", parentId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async getUserMemberships(userId: number): Promise<DbMembership[]> {
|
||||
return kysely
|
||||
.selectFrom("Membership")
|
||||
.leftJoin("Role", "Role.id", "Membership.customRoleId")
|
||||
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
|
||||
.select((eb) => [
|
||||
"Membership.teamId",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("Role")
|
||||
.select((eb) => [
|
||||
"Role.id",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("RolePermission")
|
||||
.select(["RolePermission.resource", "RolePermission.action"])
|
||||
.whereRef("RolePermission.roleId", "=", "Role.id")
|
||||
).as("permissions"),
|
||||
])
|
||||
.whereRef("Role.id", "=", "Membership.customRoleId")
|
||||
).as("role"),
|
||||
])
|
||||
.where("Membership.userId", "=", userId)
|
||||
.distinctOn(["Membership.teamId"])
|
||||
.execute();
|
||||
}
|
||||
|
||||
async checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean> {
|
||||
const [resource, action] = permission.split(".");
|
||||
const hasPermission = await kysely
|
||||
.selectFrom("RolePermission")
|
||||
.select("id")
|
||||
.where("roleId", "=", roleId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
// Global wildcard
|
||||
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
|
||||
// Resource wildcard
|
||||
eb.and([eb("resource", "=", "*"), eb("action", "=", action)]),
|
||||
// Action wildcard
|
||||
eb.and([eb("resource", "=", resource), eb("action", "=", "*")]),
|
||||
// Exact match
|
||||
eb.and([eb("resource", "=", resource), eb("action", "=", action)]),
|
||||
])
|
||||
)
|
||||
.executeTakeFirst();
|
||||
return !!hasPermission;
|
||||
}
|
||||
|
||||
async checkRolePermissions(roleId: string, permissions: PermissionString[]): Promise<boolean> {
|
||||
const permissionPairs = permissions.map((p) => {
|
||||
const [resource, action] = p.split(".");
|
||||
return { resource, action };
|
||||
});
|
||||
|
||||
const matchingPermissionsCount = await kysely
|
||||
.selectFrom("RolePermission")
|
||||
.select((eb) => eb.fn.countAll().as("count"))
|
||||
.where("roleId", "=", roleId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
// Global wildcard
|
||||
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
|
||||
// Resource wildcards
|
||||
eb.and([
|
||||
eb("resource", "=", "*"),
|
||||
eb(
|
||||
"action",
|
||||
"in",
|
||||
permissionPairs.map((p) => p.action)
|
||||
),
|
||||
]),
|
||||
// Action wildcards
|
||||
eb.and([
|
||||
eb(
|
||||
"resource",
|
||||
"in",
|
||||
permissionPairs.map((p) => p.resource)
|
||||
),
|
||||
eb("action", "=", "*"),
|
||||
]),
|
||||
// Exact matches
|
||||
eb.or(
|
||||
permissionPairs.map((p) => eb.and([eb("resource", "=", p.resource), eb("action", "=", p.action)]))
|
||||
),
|
||||
])
|
||||
)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Number(matchingPermissionsCount.count) >= permissions.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
||||
|
||||
import kysely from "@calcom/kysely";
|
||||
import type { DB } from "@calcom/kysely/types";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RoleType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { PermissionString, Resource, CrudAction, CustomAction } from "../types/permission-registry";
|
||||
|
||||
export class RoleRepository {
|
||||
async findRoleByName(name: string, teamId?: number) {
|
||||
return kysely
|
||||
.selectFrom("Role")
|
||||
.selectAll()
|
||||
.where("name", "=", name)
|
||||
.where((eb) => (teamId ? eb("teamId", "=", teamId) : eb("teamId", "is", null)))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async createRole(data: { name: string; description?: string; teamId?: number; type: RoleType }) {
|
||||
return prisma.role.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
teamId: data.teamId,
|
||||
type: data.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createRolePermissions(roleId: string, permissions: PermissionString[]) {
|
||||
return prisma.rolePermission.createMany({
|
||||
data: permissions.map((permission) => {
|
||||
const [resource, action] = permission.split(".") as [Resource, CrudAction | CustomAction];
|
||||
return { roleId, resource, action };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async findRoleWithPermissions(roleId: string) {
|
||||
const role = await kysely
|
||||
.selectFrom("Role")
|
||||
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
|
||||
.select([
|
||||
"Role.id",
|
||||
"Role.name",
|
||||
"Role.description",
|
||||
"Role.teamId",
|
||||
"Role.type",
|
||||
"Role.createdAt",
|
||||
"Role.updatedAt",
|
||||
(eb: ExpressionBuilder<DB, "Role" | "RolePermission">) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("RolePermission")
|
||||
.select(["resource", "action", "id"])
|
||||
.whereRef("RolePermission.roleId", "=", "Role.id")
|
||||
).as("permissions"),
|
||||
])
|
||||
.where("Role.id", "=", roleId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async findTeamRoles(teamId: number) {
|
||||
return kysely
|
||||
.selectFrom("Role")
|
||||
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
|
||||
.select([
|
||||
"Role.id",
|
||||
"Role.name",
|
||||
"Role.description",
|
||||
"Role.teamId",
|
||||
"Role.type",
|
||||
"Role.createdAt",
|
||||
"Role.updatedAt",
|
||||
(eb: ExpressionBuilder<DB, "Role" | "RolePermission">) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("RolePermission")
|
||||
.select(["resource", "action", "id"])
|
||||
.whereRef("RolePermission.roleId", "=", "Role.id")
|
||||
).as("permissions"),
|
||||
])
|
||||
.where((eb: ExpressionBuilder<DB, "Role" | "RolePermission">) =>
|
||||
eb.or([eb("Role.teamId", "=", teamId), eb("Role.type", "=", RoleType.SYSTEM)])
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteRolePermissions(roleId: string) {
|
||||
return kysely.deleteFrom("RolePermission").where("roleId", "=", roleId).execute();
|
||||
}
|
||||
|
||||
async deleteRole(roleId: string) {
|
||||
return kysely.deleteFrom("Role").where("id", "=", roleId).returningAll().executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateMembershipRole(membershipId: number, roleId: string | null) {
|
||||
return kysely
|
||||
.updateTable("Membership")
|
||||
.set({ customRoleId: roleId })
|
||||
.where("id", "=", membershipId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async transaction<T>(callback: (trx: typeof kysely) => Promise<T>): Promise<T> {
|
||||
return kysely.transaction().execute(callback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { vi, type Mock, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { PermissionRepository } from "../../repository/permission.repository";
|
||||
import { PermissionCheckService } from "../permission-check.service";
|
||||
|
||||
vi.mock("../../repository/permission.repository", () => ({
|
||||
PermissionRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
type MockRepository = {
|
||||
[K in keyof PermissionRepository]: Mock;
|
||||
};
|
||||
|
||||
describe("PermissionCheckService", () => {
|
||||
let service: PermissionCheckService;
|
||||
let mockRepository: MockRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepository = {
|
||||
getMembershipByMembershipId: vi.fn(),
|
||||
getMembershipByUserAndTeam: vi.fn(),
|
||||
getOrgMembership: vi.fn(),
|
||||
getUserMemberships: vi.fn(),
|
||||
checkRolePermission: vi.fn(),
|
||||
checkRolePermissions: vi.fn(),
|
||||
};
|
||||
vi.mocked(PermissionRepository).mockImplementation(
|
||||
() => mockRepository as unknown as PermissionRepository
|
||||
);
|
||||
service = new PermissionCheckService();
|
||||
});
|
||||
|
||||
describe("hasPermission", () => {
|
||||
it("should check permission by membershipId", async () => {
|
||||
const membership = {
|
||||
id: 1,
|
||||
teamId: 1,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "ADMIN" as MembershipRole,
|
||||
customRoleId: "admin_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team_parentId: null,
|
||||
};
|
||||
|
||||
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(membership);
|
||||
mockRepository.checkRolePermission.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await service.hasPermission({ membershipId: 1 }, "eventType.create");
|
||||
expect(result).toBe(true);
|
||||
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
|
||||
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.create");
|
||||
});
|
||||
|
||||
it("should return false if neither team nor org has permission", async () => {
|
||||
const teamMembership = {
|
||||
id: 1,
|
||||
teamId: 2,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "MEMBER" as MembershipRole,
|
||||
customRoleId: "team_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team_parentId: 1,
|
||||
};
|
||||
|
||||
const orgMembership = {
|
||||
id: 2,
|
||||
teamId: 1,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "MEMBER" as MembershipRole,
|
||||
customRoleId: "org_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(teamMembership);
|
||||
mockRepository.checkRolePermission.mockResolvedValueOnce(false);
|
||||
mockRepository.getOrgMembership.mockResolvedValueOnce(orgMembership);
|
||||
mockRepository.checkRolePermission.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await service.hasPermission({ membershipId: 1 }, "eventType.create");
|
||||
expect(result).toBe(false);
|
||||
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
|
||||
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("team_role", "eventType.create");
|
||||
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 1);
|
||||
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("org_role", "eventType.create");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPermissions", () => {
|
||||
it("should check multiple permissions (AND condition)", async () => {
|
||||
const membership = {
|
||||
id: 1,
|
||||
teamId: 1,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "ADMIN" as MembershipRole,
|
||||
customRoleId: "admin_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team_parentId: null,
|
||||
};
|
||||
|
||||
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(membership);
|
||||
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await service.hasPermissions({ membershipId: 1 }, ["eventType.create", "team.invite"]);
|
||||
expect(result).toBe(true);
|
||||
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
|
||||
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", [
|
||||
"eventType.create",
|
||||
"team.invite",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should check multiple permissions in parent organization if team permissions not found", async () => {
|
||||
const teamMembership = {
|
||||
id: 1,
|
||||
teamId: 2,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "MEMBER" as MembershipRole,
|
||||
customRoleId: "team_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team_parentId: 1,
|
||||
};
|
||||
|
||||
const orgMembership = {
|
||||
id: 2,
|
||||
teamId: 1,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "ADMIN" as MembershipRole,
|
||||
customRoleId: "org_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(teamMembership);
|
||||
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
|
||||
mockRepository.getOrgMembership.mockResolvedValueOnce(orgMembership);
|
||||
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await service.hasPermissions({ membershipId: 1 }, ["eventType.create", "team.invite"]);
|
||||
expect(result).toBe(true);
|
||||
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
|
||||
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("team_role", [
|
||||
"eventType.create",
|
||||
"team.invite",
|
||||
]);
|
||||
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 1);
|
||||
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("org_role", [
|
||||
"eventType.create",
|
||||
"team.invite",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return false if neither team nor org has all required permissions", async () => {
|
||||
const teamMembership = {
|
||||
id: 1,
|
||||
teamId: 2,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "MEMBER" as MembershipRole,
|
||||
customRoleId: "team_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
team_parentId: 1,
|
||||
};
|
||||
|
||||
const orgMembership = {
|
||||
id: 2,
|
||||
teamId: 1,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "MEMBER" as MembershipRole,
|
||||
customRoleId: "org_role",
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce(teamMembership);
|
||||
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
|
||||
mockRepository.getOrgMembership.mockResolvedValueOnce(orgMembership);
|
||||
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await service.hasPermissions({ membershipId: 1 }, ["eventType.create", "team.invite"]);
|
||||
expect(result).toBe(false);
|
||||
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
|
||||
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("team_role", [
|
||||
"eventType.create",
|
||||
"team.invite",
|
||||
]);
|
||||
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 1);
|
||||
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("org_role", [
|
||||
"eventType.create",
|
||||
"team.invite",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { PermissionString } from "../../types/permission-registry";
|
||||
import { CrudAction, Resource } from "../../types/permission-registry";
|
||||
import { PERMISSION_REGISTRY } from "../../types/permission-registry";
|
||||
import { PermissionService } from "../permission.service";
|
||||
|
||||
describe("PermissionService", () => {
|
||||
let service: PermissionService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PermissionService();
|
||||
});
|
||||
|
||||
describe("validatePermission", () => {
|
||||
it("should validate a valid permission", () => {
|
||||
expect(service.validatePermission("eventType.create" as PermissionString)).toBe(true);
|
||||
expect(service.validatePermission("team.invite" as PermissionString)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject an invalid permission", () => {
|
||||
expect(service.validatePermission("invalid.action" as PermissionString)).toBe(false);
|
||||
expect(service.validatePermission("eventType.invalid" as PermissionString)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePermissions", () => {
|
||||
it("should validate multiple valid permissions", () => {
|
||||
const permissions: PermissionString[] = [
|
||||
"eventType.create",
|
||||
"team.invite",
|
||||
"organization.manageBilling",
|
||||
];
|
||||
expect(service.validatePermissions(permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject if any permission is invalid", () => {
|
||||
const permissions = [
|
||||
"eventType.create",
|
||||
"invalid.action",
|
||||
"organization.billing",
|
||||
] as PermissionString[];
|
||||
expect(service.validatePermissions(permissions)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllPermissions", () => {
|
||||
it("should return all permissions from registry", () => {
|
||||
const permissions = service.getAllPermissions();
|
||||
const expectedCount = Object.values(PERMISSION_REGISTRY).reduce(
|
||||
(count, resource) => count + Object.keys(resource).length,
|
||||
0
|
||||
);
|
||||
expect(permissions).toHaveLength(expectedCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPermissionsByCategory", () => {
|
||||
it("should return permissions for a specific category", () => {
|
||||
const eventPermissions = service.getPermissionsByCategory("event");
|
||||
expect(eventPermissions.every((p) => p.category === "event")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return empty array for non-existent category", () => {
|
||||
const permissions = service.getPermissionsByCategory("nonexistent");
|
||||
expect(permissions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPermissionCategories", () => {
|
||||
it("should return all unique categories", () => {
|
||||
const categories = service.getPermissionCategories();
|
||||
const uniqueCategories = new Set(
|
||||
Object.values(PERMISSION_REGISTRY).flatMap((resource) =>
|
||||
Object.values(resource).map((p) => p.category)
|
||||
)
|
||||
);
|
||||
expect(categories).toHaveLength(uniqueCategories.size);
|
||||
expect(categories.every((category) => typeof category === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPermissionsByResource", () => {
|
||||
it("should return permissions for a specific resource", () => {
|
||||
const eventTypePermissions = service.getPermissionsByResource(Resource.EventType);
|
||||
expect(eventTypePermissions.every((p) => p.resource === Resource.EventType)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return empty array for non-existent resource", () => {
|
||||
const permissions = service.getPermissionsByResource("nonexistent" as Resource);
|
||||
expect(permissions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPermissionsByAction", () => {
|
||||
it("should return permissions for a specific action", () => {
|
||||
const createPermissions = service.getPermissionsByAction(CrudAction.Create);
|
||||
expect(createPermissions.every((p) => p.action === CrudAction.Create)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return empty array for non-existent action", () => {
|
||||
const permissions = service.getPermissionsByAction("nonexistent" as CrudAction);
|
||||
expect(permissions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { vi, type Mock, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import { RoleType } from "@calcom/prisma/enums";
|
||||
|
||||
import { RoleRepository } from "../../repository/role.repository";
|
||||
import type { PermissionString } from "../../types/permission-registry";
|
||||
import { RoleService } from "../role.service";
|
||||
|
||||
vi.mock("../../repository/role.repository", () => ({
|
||||
RoleRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
type MockRepository = {
|
||||
[K in keyof RoleRepository]: Mock;
|
||||
};
|
||||
|
||||
describe("RoleService", () => {
|
||||
let service: RoleService;
|
||||
let mockRepository: MockRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepository = {
|
||||
findRoleByName: vi.fn(),
|
||||
createRole: vi.fn(),
|
||||
createRolePermissions: vi.fn(),
|
||||
findRoleWithPermissions: vi.fn(),
|
||||
findTeamRoles: vi.fn(),
|
||||
deleteRolePermissions: vi.fn(),
|
||||
deleteRole: vi.fn(),
|
||||
updateMembershipRole: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
};
|
||||
vi.mocked(RoleRepository).mockImplementation(() => mockRepository as unknown as RoleRepository);
|
||||
service = new RoleService();
|
||||
});
|
||||
|
||||
describe("createRole", () => {
|
||||
it("should create a role with permissions", async () => {
|
||||
const roleData = {
|
||||
name: "Test Role",
|
||||
teamId: 1,
|
||||
permissions: ["eventType.create", "eventType.read"] as PermissionString[],
|
||||
};
|
||||
|
||||
const mockRole = {
|
||||
id: "role-id",
|
||||
name: roleData.name,
|
||||
teamId: roleData.teamId,
|
||||
type: RoleType.CUSTOM,
|
||||
};
|
||||
|
||||
const mockPermissions = [
|
||||
{ roleId: "role-id", resource: "eventType", action: "create" },
|
||||
{ roleId: "role-id", resource: "eventType", action: "read" },
|
||||
];
|
||||
|
||||
mockRepository.findRoleByName.mockResolvedValueOnce(null);
|
||||
mockRepository.transaction.mockImplementationOnce(async (callback) => {
|
||||
mockRepository.createRole.mockResolvedValueOnce(mockRole);
|
||||
mockRepository.createRolePermissions.mockResolvedValueOnce({ count: 2 });
|
||||
return callback(mockRepository);
|
||||
});
|
||||
|
||||
const result = await service.createRole(roleData);
|
||||
expect(result).toBeDefined();
|
||||
expect(mockRepository.createRole).toHaveBeenCalledWith({
|
||||
name: roleData.name,
|
||||
teamId: roleData.teamId,
|
||||
type: RoleType.CUSTOM,
|
||||
});
|
||||
expect(mockRepository.createRolePermissions).toHaveBeenCalledWith(mockRole.id, roleData.permissions);
|
||||
});
|
||||
|
||||
it("should throw error if role name already exists", async () => {
|
||||
const roleData = {
|
||||
name: "Test Role",
|
||||
teamId: 1,
|
||||
permissions: ["eventType.create"] as PermissionString[],
|
||||
};
|
||||
|
||||
mockRepository.findRoleByName.mockResolvedValueOnce({
|
||||
id: "existing-role",
|
||||
name: roleData.name,
|
||||
});
|
||||
|
||||
await expect(service.createRole(roleData)).rejects.toThrow(
|
||||
`Role with name "${roleData.name}" already exists`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRole", () => {
|
||||
it("should return role with permissions", async () => {
|
||||
const roleId = "role-id";
|
||||
const role = {
|
||||
id: roleId,
|
||||
name: "Test Role",
|
||||
teamId: 1,
|
||||
type: RoleType.CUSTOM,
|
||||
permissions: [
|
||||
{ resource: "eventType", action: "create" },
|
||||
{ resource: "eventType", action: "read" },
|
||||
],
|
||||
};
|
||||
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(role);
|
||||
|
||||
const result = await service.getRole(roleId);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.permissions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return null for non-existent role", async () => {
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.getRole("non-existent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamRoles", () => {
|
||||
it("should return all roles for a team", async () => {
|
||||
const teamId = 1;
|
||||
const roles = [
|
||||
{
|
||||
id: "role-1",
|
||||
name: "Role 1",
|
||||
teamId,
|
||||
type: RoleType.CUSTOM,
|
||||
permissions: [{ roleId: "role-1", resource: "eventType", action: "create" }],
|
||||
},
|
||||
{
|
||||
id: "role-2",
|
||||
name: "Role 2",
|
||||
teamId,
|
||||
type: RoleType.CUSTOM,
|
||||
permissions: [{ roleId: "role-2", resource: "eventType", action: "read" }],
|
||||
},
|
||||
];
|
||||
|
||||
mockRepository.findTeamRoles.mockResolvedValueOnce(roles);
|
||||
|
||||
const result = await service.getTeamRoles(teamId);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].permissions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateRolePermissions", () => {
|
||||
it("should update role permissions", async () => {
|
||||
const roleId = "role-id";
|
||||
const permissions = ["eventType.create", "eventType.read"] as PermissionString[];
|
||||
|
||||
const mockRole = {
|
||||
id: roleId,
|
||||
name: "Test Role",
|
||||
teamId: 1,
|
||||
type: RoleType.CUSTOM,
|
||||
permissions: [
|
||||
{ roleId, resource: "eventType", action: "create" },
|
||||
{ roleId, resource: "eventType", action: "read" },
|
||||
],
|
||||
};
|
||||
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(mockRole);
|
||||
mockRepository.transaction.mockImplementationOnce(async (callback) => {
|
||||
mockRepository.deleteRolePermissions.mockResolvedValueOnce({ count: 2 });
|
||||
mockRepository.createRolePermissions.mockResolvedValueOnce({ count: 2 });
|
||||
return callback(mockRepository);
|
||||
});
|
||||
|
||||
const result = await service.updateRolePermissions(roleId, permissions);
|
||||
expect(result).toBeDefined();
|
||||
expect(mockRepository.deleteRolePermissions).toHaveBeenCalledWith(roleId);
|
||||
expect(mockRepository.createRolePermissions).toHaveBeenCalledWith(roleId, permissions);
|
||||
});
|
||||
|
||||
it("should throw error if role does not exist", async () => {
|
||||
const roleId = "non-existent-role";
|
||||
const permissions = ["eventType.create"] as PermissionString[];
|
||||
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.updateRolePermissions(roleId, permissions)).rejects.toThrow("Role not found");
|
||||
});
|
||||
|
||||
it("should throw error if trying to update system role", async () => {
|
||||
const roleId = "system-role";
|
||||
const permissions = ["eventType.create"] as PermissionString[];
|
||||
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce({
|
||||
id: roleId,
|
||||
type: RoleType.SYSTEM,
|
||||
});
|
||||
|
||||
await expect(service.updateRolePermissions(roleId, permissions)).rejects.toThrow(
|
||||
"Cannot update default roles"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteRole", () => {
|
||||
it("should delete a custom role", async () => {
|
||||
const roleId = "role-id";
|
||||
const mockRole = {
|
||||
id: roleId,
|
||||
type: RoleType.CUSTOM,
|
||||
};
|
||||
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(mockRole);
|
||||
mockRepository.transaction.mockImplementationOnce(async (callback) => {
|
||||
mockRepository.deleteRolePermissions.mockResolvedValueOnce({ count: 1 });
|
||||
mockRepository.deleteRole.mockResolvedValueOnce(mockRole);
|
||||
return callback(mockRepository);
|
||||
});
|
||||
|
||||
const result = await service.deleteRole(roleId);
|
||||
expect(result).toBeDefined();
|
||||
expect(mockRepository.deleteRolePermissions).toHaveBeenCalledWith(roleId);
|
||||
expect(mockRepository.deleteRole).toHaveBeenCalledWith(roleId);
|
||||
});
|
||||
|
||||
it("should throw error if trying to delete system role", async () => {
|
||||
const roleId = "system-role";
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce({
|
||||
id: roleId,
|
||||
type: RoleType.SYSTEM,
|
||||
});
|
||||
|
||||
await expect(service.deleteRole(roleId)).rejects.toThrow("Cannot delete default roles");
|
||||
});
|
||||
|
||||
it("should throw error if role does not exist", async () => {
|
||||
const roleId = "non-existent";
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.deleteRole(roleId)).rejects.toThrow("Role not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeUserRole", () => {
|
||||
const membershipId = 123;
|
||||
const roleId = "role-id";
|
||||
|
||||
it("should change user's role successfully", async () => {
|
||||
const mockRole = {
|
||||
id: roleId,
|
||||
name: "Test Role",
|
||||
teamId: 1,
|
||||
type: RoleType.CUSTOM,
|
||||
permissions: [
|
||||
{ roleId, resource: "eventType", action: "create" },
|
||||
{ roleId, resource: "eventType", action: "read" },
|
||||
],
|
||||
};
|
||||
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(mockRole);
|
||||
mockRepository.updateMembershipRole.mockResolvedValueOnce({
|
||||
id: membershipId,
|
||||
customRoleId: roleId,
|
||||
});
|
||||
|
||||
const result = await service.changeUserRole(membershipId, roleId);
|
||||
expect(result).toBeDefined();
|
||||
expect(mockRepository.updateMembershipRole).toHaveBeenCalledWith(membershipId, roleId);
|
||||
});
|
||||
|
||||
it("should throw error if role does not exist", async () => {
|
||||
mockRepository.findRoleWithPermissions.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.changeUserRole(membershipId, roleId)).rejects.toThrow("Role not found");
|
||||
expect(mockRepository.updateMembershipRole).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { PermissionRepository } from "../repository/permission.repository";
|
||||
import type { PermissionString } from "../types/permission-registry";
|
||||
|
||||
export function transformDbPermissionsToTeamPermissions(
|
||||
memberships: {
|
||||
teamId: number;
|
||||
role: { id: string | null; permissions: { resource: string | null; action: string | null }[] } | null;
|
||||
}[]
|
||||
): Record<number, { roleId: string; permissions: PermissionString[] }> {
|
||||
const teamPermissions: Record<number, { roleId: string; permissions: PermissionString[] }> = {};
|
||||
|
||||
for (const membership of memberships) {
|
||||
if (!membership.teamId || !membership.role?.id || !membership.role.permissions) continue;
|
||||
|
||||
const validPermissions = membership.role.permissions.filter(
|
||||
(p): p is { resource: string; action: string } =>
|
||||
typeof p.resource === "string" &&
|
||||
typeof p.action === "string" &&
|
||||
p.resource !== null &&
|
||||
p.action !== null
|
||||
);
|
||||
|
||||
teamPermissions[membership.teamId] = {
|
||||
roleId: membership.role.id,
|
||||
permissions: validPermissions.map((p) => `${p.resource}.${p.action}` as PermissionString),
|
||||
};
|
||||
}
|
||||
|
||||
return teamPermissions;
|
||||
}
|
||||
|
||||
export class PermissionCheckService {
|
||||
private repository: PermissionRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new PermissionRepository();
|
||||
}
|
||||
|
||||
async getUserPermissions(userId: number) {
|
||||
const memberships = await this.repository.getUserMemberships(userId);
|
||||
return transformDbPermissionsToTeamPermissions(memberships);
|
||||
}
|
||||
|
||||
async hasPermission(
|
||||
query: { membershipId?: number; userId?: number; teamId?: number },
|
||||
permission: PermissionString
|
||||
): Promise<boolean> {
|
||||
const { membership, orgMembership } = await this.getMembership(query);
|
||||
|
||||
// Check team membership first
|
||||
if (membership?.customRoleId) {
|
||||
const hasTeamPermission = await this.repository.checkRolePermission(
|
||||
membership.customRoleId,
|
||||
permission
|
||||
);
|
||||
if (hasTeamPermission) return true;
|
||||
}
|
||||
|
||||
// If no team permission and there's an org membership, check org permissions
|
||||
if (orgMembership?.customRoleId) {
|
||||
return this.repository.checkRolePermission(orgMembership.customRoleId, permission);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async hasPermissions(
|
||||
query: { membershipId?: number; userId?: number; teamId?: number },
|
||||
permissions: PermissionString[]
|
||||
): Promise<boolean> {
|
||||
const { membership, orgMembership } = await this.getMembership(query);
|
||||
|
||||
// Check team membership first
|
||||
if (membership?.customRoleId) {
|
||||
const hasTeamPermissions = await this.repository.checkRolePermissions(
|
||||
membership.customRoleId,
|
||||
permissions
|
||||
);
|
||||
if (hasTeamPermissions) return true;
|
||||
}
|
||||
|
||||
// If no team permissions and there's an org membership, check org permissions
|
||||
if (orgMembership?.customRoleId) {
|
||||
return this.repository.checkRolePermissions(orgMembership.customRoleId, permissions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getMembership(query: { membershipId?: number; userId?: number; teamId?: number }) {
|
||||
let membership = null;
|
||||
let orgMembership = null;
|
||||
|
||||
// Get the team membership
|
||||
if (query.membershipId) {
|
||||
membership = await this.repository.getMembershipByMembershipId(query.membershipId);
|
||||
} else if (query.userId && query.teamId) {
|
||||
membership = await this.repository.getMembershipByUserAndTeam(query.userId, query.teamId);
|
||||
}
|
||||
|
||||
// If we found a membership and the team has a parent org, get the org membership
|
||||
if (membership?.team_parentId) {
|
||||
orgMembership = await this.repository.getOrgMembership(membership.userId, membership.team_parentId);
|
||||
}
|
||||
|
||||
return { membership, orgMembership };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
PermissionString,
|
||||
Resource,
|
||||
CrudAction,
|
||||
CustomAction,
|
||||
PermissionDetails,
|
||||
} from "../types/permission-registry";
|
||||
import { PERMISSION_REGISTRY } from "../types/permission-registry";
|
||||
|
||||
export class PermissionService {
|
||||
validatePermission(permission: PermissionString): boolean {
|
||||
const [resource, action] = permission.split(".") as [Resource, CrudAction | CustomAction];
|
||||
return !!PERMISSION_REGISTRY[resource]?.[action];
|
||||
}
|
||||
|
||||
validatePermissions(permissions: PermissionString[]): boolean {
|
||||
return permissions.every((permission) => this.validatePermission(permission));
|
||||
}
|
||||
|
||||
// Helper function to check if a permission matches a pattern (including wildcards)
|
||||
permissionMatches(pattern: PermissionString, permission: PermissionString): boolean {
|
||||
// Handle full wildcard
|
||||
if (pattern === "*.*") return true;
|
||||
|
||||
const [patternResource, patternAction] = pattern.split(".") as [
|
||||
Resource | "*",
|
||||
CrudAction | CustomAction | "*"
|
||||
];
|
||||
const [permissionResource, permissionAction] = permission.split(".") as [
|
||||
Resource,
|
||||
CrudAction | CustomAction
|
||||
];
|
||||
|
||||
// Check if resource matches (either exact match or wildcard)
|
||||
const resourceMatches = patternResource === "*" || patternResource === permissionResource;
|
||||
|
||||
// Check if action matches (either exact match or wildcard)
|
||||
const actionMatches = patternAction === "*" || patternAction === permissionAction;
|
||||
|
||||
return resourceMatches && actionMatches;
|
||||
}
|
||||
|
||||
// Helper function to create a permission string
|
||||
createPermissionString(
|
||||
resource: Resource | "*",
|
||||
action: CrudAction | CustomAction | "*",
|
||||
isCustom = false
|
||||
): PermissionString {
|
||||
const prefix = isCustom ? "custom:" : "";
|
||||
return `${prefix}${resource}.${action}` as PermissionString;
|
||||
}
|
||||
|
||||
// Helper function to get all permissions as an array
|
||||
getAllPermissions(): Array<{ resource: Resource; action: CrudAction | CustomAction } & PermissionDetails> {
|
||||
const permissions: Array<{ resource: Resource; action: CrudAction | CustomAction } & PermissionDetails> =
|
||||
[];
|
||||
|
||||
Object.entries(PERMISSION_REGISTRY).forEach(([resource, actions]) => {
|
||||
Object.entries(actions).forEach(([action, details]) => {
|
||||
permissions.push({
|
||||
resource: resource as Resource,
|
||||
action: action as CrudAction | CustomAction,
|
||||
...details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
getPermissionsByCategory(category: string) {
|
||||
return this.getAllPermissions().filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
getPermissionCategories(): string[] {
|
||||
return Array.from(new Set(this.getAllPermissions().map((p) => p.category)));
|
||||
}
|
||||
|
||||
getPermissionsByResource(resource: Resource) {
|
||||
const resourcePermissions = PERMISSION_REGISTRY[resource];
|
||||
if (!resourcePermissions) return [];
|
||||
|
||||
return Object.entries(resourcePermissions).map(([action, details]) => ({
|
||||
resource,
|
||||
action: action as CrudAction | CustomAction,
|
||||
...details,
|
||||
}));
|
||||
}
|
||||
|
||||
getPermissionsByAction(action: CrudAction | CustomAction) {
|
||||
return this.getAllPermissions().filter((p) => p.action === action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { MembershipRole, RoleType } from "@calcom/prisma/enums";
|
||||
|
||||
import { RoleRepository } from "../repository/role.repository";
|
||||
import type { PermissionString } from "../types/permission-registry";
|
||||
import { PermissionService } from "./permission.service";
|
||||
|
||||
// These IDs must match the ones in the migration
|
||||
const DEFAULT_ROLE_IDS = {
|
||||
[MembershipRole.OWNER]: "owner_role",
|
||||
[MembershipRole.ADMIN]: "admin_role",
|
||||
[MembershipRole.MEMBER]: "member_role",
|
||||
} as const;
|
||||
|
||||
export class RoleService {
|
||||
private repository: RoleRepository;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor() {
|
||||
this.repository = new RoleRepository();
|
||||
this.permissionService = new PermissionService();
|
||||
}
|
||||
|
||||
async createRole(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
teamId?: number;
|
||||
permissions: PermissionString[];
|
||||
}) {
|
||||
// Check if role name conflicts with default roles
|
||||
const existingRole = await this.repository.findRoleByName(data.name, data.teamId);
|
||||
if (existingRole) {
|
||||
throw new Error(`Role with name "${data.name}" already exists`);
|
||||
}
|
||||
|
||||
// Validate permissions
|
||||
if (!this.permissionService.validatePermissions(data.permissions)) {
|
||||
throw new Error("Invalid permissions provided");
|
||||
}
|
||||
|
||||
return this.repository.transaction(async (trx) => {
|
||||
// Create role
|
||||
const role = await this.repository.createRole({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
teamId: data.teamId,
|
||||
type: RoleType.CUSTOM,
|
||||
});
|
||||
|
||||
// Create permissions
|
||||
const permissions = await this.repository.createRolePermissions(role.id, data.permissions);
|
||||
|
||||
return { ...role, permissions };
|
||||
});
|
||||
}
|
||||
|
||||
async getDefaultRoleId(role: MembershipRole): Promise<string> {
|
||||
return DEFAULT_ROLE_IDS[role];
|
||||
}
|
||||
|
||||
async assignRoleToMember(roleId: string, membershipId: number) {
|
||||
return this.repository.updateMembershipRole(membershipId, roleId);
|
||||
}
|
||||
|
||||
async getRolePermissions(roleId: string) {
|
||||
const role = await this.repository.findRoleWithPermissions(roleId);
|
||||
return role?.permissions ?? [];
|
||||
}
|
||||
|
||||
async removeRoleFromMember(membershipId: number) {
|
||||
return this.repository.updateMembershipRole(membershipId, null);
|
||||
}
|
||||
|
||||
async changeUserRole(membershipId: number, roleId: string) {
|
||||
// Verify role exists first
|
||||
const role = await this.getRole(roleId);
|
||||
if (!role) {
|
||||
throw new Error("Role not found");
|
||||
}
|
||||
|
||||
return this.repository.updateMembershipRole(membershipId, roleId);
|
||||
}
|
||||
|
||||
async deleteRole(roleId: string) {
|
||||
const role = await this.repository.findRoleWithPermissions(roleId);
|
||||
if (!role) {
|
||||
throw new Error("Role not found");
|
||||
}
|
||||
|
||||
// Don't allow deleting default roles
|
||||
if (role.type === RoleType.SYSTEM) {
|
||||
throw new Error("Cannot delete default roles");
|
||||
}
|
||||
|
||||
return this.repository.transaction(async (trx) => {
|
||||
// Delete permissions first
|
||||
await this.repository.deleteRolePermissions(roleId);
|
||||
// Then delete the role
|
||||
return this.repository.deleteRole(roleId);
|
||||
});
|
||||
}
|
||||
|
||||
async getRole(roleId: string) {
|
||||
return this.repository.findRoleWithPermissions(roleId);
|
||||
}
|
||||
|
||||
async getTeamRoles(teamId: number) {
|
||||
return this.repository.findTeamRoles(teamId);
|
||||
}
|
||||
|
||||
async updateRolePermissions(roleId: string, permissions: PermissionString[]) {
|
||||
const role = await this.getRole(roleId);
|
||||
if (!role) {
|
||||
throw new Error("Role not found");
|
||||
}
|
||||
|
||||
// Don't allow updating default roles
|
||||
if (role.type === RoleType.SYSTEM) {
|
||||
throw new Error("Cannot update default roles");
|
||||
}
|
||||
|
||||
// Validate permissions
|
||||
if (!this.permissionService.validatePermissions(permissions)) {
|
||||
throw new Error("Invalid permissions provided");
|
||||
}
|
||||
|
||||
return this.repository.transaction(async (trx) => {
|
||||
// Delete existing permissions
|
||||
await this.repository.deleteRolePermissions(roleId);
|
||||
// Create new permissions
|
||||
const newPermissions = await this.repository.createRolePermissions(roleId, permissions);
|
||||
return { ...role, permissions: newPermissions };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { PermissionString } from "../types/permission-registry";
|
||||
|
||||
export interface TeamPermissions {
|
||||
roleId: string;
|
||||
permissions: Set<PermissionString>;
|
||||
}
|
||||
|
||||
interface PermissionStore {
|
||||
teamPermissions: Map<number, TeamPermissions>;
|
||||
setTeamPermissions: (
|
||||
permissions: Record<number, { roleId: string; permissions: PermissionString[] }>
|
||||
) => void;
|
||||
hasPermission: (teamId: number, permission: PermissionString) => boolean;
|
||||
hasPermissions: (teamId: number, permissions: PermissionString[]) => boolean;
|
||||
}
|
||||
|
||||
export const usePermissionStore = create<PermissionStore>((set, get) => ({
|
||||
teamPermissions: new Map(),
|
||||
setTeamPermissions: (permissions) => {
|
||||
const permissionsMap = new Map();
|
||||
Object.entries(permissions).forEach(([teamId, { roleId, permissions: perms }]) => {
|
||||
permissionsMap.set(Number(teamId), {
|
||||
roleId,
|
||||
permissions: new Set(perms),
|
||||
});
|
||||
});
|
||||
set({ teamPermissions: permissionsMap });
|
||||
},
|
||||
hasPermission: (teamId, permission) => {
|
||||
const teamPerms = get().teamPermissions.get(teamId);
|
||||
if (!teamPerms) return false;
|
||||
return teamPerms.permissions.has(permission);
|
||||
},
|
||||
hasPermissions: (teamId, permissions) => {
|
||||
const teamPerms = get().teamPermissions.get(teamId);
|
||||
if (!teamPerms) return false;
|
||||
return permissions.every((p) => teamPerms.permissions.has(p));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,125 @@
|
||||
export enum CrudAction {
|
||||
Create = "create",
|
||||
Read = "read",
|
||||
Update = "update",
|
||||
Delete = "delete",
|
||||
All = "*", // Wildcard for all CRUD actions
|
||||
}
|
||||
|
||||
export enum Resource {
|
||||
EventType = "eventType",
|
||||
Booking = "booking",
|
||||
Team = "team",
|
||||
Organization = "organization",
|
||||
Insights = "insights",
|
||||
Availability = "availability",
|
||||
Workflow = "workflow",
|
||||
RoutingForm = "routingForm",
|
||||
ApiKey = "apiKey",
|
||||
All = "*", // Wildcard for all resources
|
||||
}
|
||||
|
||||
export enum CustomAction {
|
||||
Manage = "manage", // Full control over a resource
|
||||
Invite = "invite", // Invite members to team/org
|
||||
Remove = "remove", // Remove members from team/org
|
||||
Override = "override", // Override availability
|
||||
ReadRecordings = "readRecordings", // Access booking recordings
|
||||
ManageBilling = "manageBilling", // Manage org billing
|
||||
ChangeMemberRole = "changeMemberRole", // Change role of team members
|
||||
ListMembers = "listMembers", // List organization members
|
||||
ReadTeamBookings = "readTeamBookings", // Read team bookings
|
||||
ReadOrgBookings = "readOrgBookings", // Read organization bookings
|
||||
FindKeyOfType = "findKeyOfType", // Find API keys by type
|
||||
All = "*", // Wildcard for all custom actions
|
||||
}
|
||||
|
||||
export type CrudScope = `${Resource}.${CrudAction}` | `${Resource}.*` | `*.*`;
|
||||
export type CustomScope = `${Resource}.${CustomAction}` | `${Resource}.*` | `*.*`;
|
||||
export type PermissionString = CrudScope | CustomScope;
|
||||
|
||||
export type PermissionDetails = {
|
||||
description: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
export type ResourcePermissions = {
|
||||
[K in CrudAction | CustomAction]?: PermissionDetails;
|
||||
};
|
||||
|
||||
export type PermissionRegistry = {
|
||||
[K in Resource]: ResourcePermissions;
|
||||
};
|
||||
|
||||
// Keep in mind these are on a team/organization level, not a user level
|
||||
export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
[Resource.All]: {
|
||||
[CrudAction.All]: { description: "All actions on all resources", category: "system" },
|
||||
},
|
||||
[Resource.EventType]: {
|
||||
[CrudAction.Create]: { description: "Create event types", category: "event" },
|
||||
[CrudAction.Read]: { description: "View event types", category: "event" },
|
||||
[CrudAction.Update]: { description: "Update event types", category: "event" },
|
||||
[CrudAction.Delete]: { description: "Delete event types", category: "event" },
|
||||
[CustomAction.Manage]: { description: "All actions on event types", category: "event" },
|
||||
},
|
||||
[Resource.Team]: {
|
||||
[CrudAction.Create]: { description: "Create teams", category: "team" },
|
||||
[CrudAction.Read]: { description: "View team details", category: "team" },
|
||||
[CrudAction.Update]: { description: "Update settings", category: "team" },
|
||||
[CrudAction.Delete]: { description: "Delete team", category: "team" },
|
||||
[CustomAction.Invite]: { description: "Invite team members", category: "team" },
|
||||
[CustomAction.Remove]: { description: "Remove team members", category: "team" },
|
||||
[CustomAction.ChangeMemberRole]: { description: "Change role of team members", category: "team" },
|
||||
[CustomAction.Manage]: { description: "All actions on teams", category: "team" },
|
||||
},
|
||||
[Resource.Organization]: {
|
||||
[CrudAction.Create]: { description: "Create organization", category: "org" },
|
||||
[CrudAction.Read]: { description: "View organization details", category: "org" },
|
||||
[CustomAction.ListMembers]: { description: "List organization members", category: "org" },
|
||||
[CustomAction.Invite]: { description: "Invite organization members", category: "org" },
|
||||
[CustomAction.Remove]: { description: "Remove organization members", category: "org" },
|
||||
[CustomAction.ManageBilling]: { description: "Manage organization billing", category: "org" },
|
||||
[CrudAction.Update]: { description: "Edit organization settings", category: "org" },
|
||||
[CustomAction.Manage]: { description: "All actions on organizations", category: "org" },
|
||||
},
|
||||
[Resource.Booking]: {
|
||||
[CrudAction.Read]: { description: "View bookings", category: "booking" },
|
||||
[CustomAction.ReadTeamBookings]: { description: "View team bookings", category: "booking" },
|
||||
[CustomAction.ReadOrgBookings]: { description: "View organization bookings", category: "booking" },
|
||||
[CustomAction.ReadRecordings]: { description: "View booking recordings", category: "booking" },
|
||||
[CrudAction.Update]: { description: "Update bookings", category: "booking" },
|
||||
[CustomAction.Manage]: { description: "All actions on bookings", category: "booking" },
|
||||
},
|
||||
[Resource.Insights]: {
|
||||
[CrudAction.Read]: { description: "View team insights and analytics", category: "insights" },
|
||||
[CustomAction.Manage]: { description: "Manage team insights and analytics", category: "insights" },
|
||||
},
|
||||
[Resource.Availability]: {
|
||||
[CrudAction.Read]: { description: "View availability", category: "availability" },
|
||||
[CrudAction.Update]: { description: "Update own availability", category: "availability" },
|
||||
[CustomAction.Override]: { description: "Override team member availability", category: "availability" },
|
||||
[CustomAction.Manage]: { description: "Manage all availability settings", category: "availability" },
|
||||
},
|
||||
[Resource.Workflow]: {
|
||||
[CrudAction.Create]: { description: "Create workflows", category: "workflow" },
|
||||
[CrudAction.Read]: { description: "View workflows", category: "workflow" },
|
||||
[CrudAction.Update]: { description: "Update workflows", category: "workflow" },
|
||||
[CrudAction.Delete]: { description: "Delete workflows", category: "workflow" },
|
||||
[CustomAction.Manage]: { description: "All actions on workflows", category: "workflow" },
|
||||
},
|
||||
[Resource.RoutingForm]: {
|
||||
[CrudAction.Create]: { description: "Create routing forms", category: "routing" },
|
||||
[CrudAction.Read]: { description: "View routing forms", category: "routing" },
|
||||
[CrudAction.Update]: { description: "Update routing forms", category: "routing" },
|
||||
[CrudAction.Delete]: { description: "Delete routing forms", category: "routing" },
|
||||
[CustomAction.Manage]: { description: "All actions on routing forms", category: "routing" },
|
||||
},
|
||||
[Resource.ApiKey]: {
|
||||
[CrudAction.Create]: { description: "Create API keys", category: "apiKey" },
|
||||
[CrudAction.Read]: { description: "View API keys", category: "apiKey" },
|
||||
[CustomAction.FindKeyOfType]: { description: "Find API keys by type", category: "apiKey" },
|
||||
[CrudAction.Delete]: { description: "Delete API keys", category: "apiKey" },
|
||||
[CustomAction.Manage]: { description: "All actions on API keys", category: "apiKey" },
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { lockUser, LockReason } from "@calcom/lib/autoLock";
|
||||
import { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewer/workflows/util";
|
||||
|
||||
import { scanWorkflowBody } from "./scanWorkflowBody";
|
||||
import { scanWorkflowBody, iffyScanBody } from "./scanWorkflowBody";
|
||||
|
||||
vi.mock("@calcom/lib/autoLock", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@calcom/lib/autoLock")>();
|
||||
@@ -19,6 +19,14 @@ vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
|
||||
scheduleWorkflowNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./scanWorkflowBody", async (importActual) => {
|
||||
const actual = await importActual<typeof import("./scanWorkflowBody")>();
|
||||
return {
|
||||
...actual,
|
||||
iffyScanBody: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockWorkflowStep = {
|
||||
id: 1,
|
||||
reminderBody: "Test reminder body",
|
||||
@@ -58,7 +66,7 @@ describe("scanWorkflowBody", () => {
|
||||
|
||||
await scanWorkflowBody(payload);
|
||||
|
||||
expect(prismaMock.workflowStep.findMany).not.toHaveBeenCalled();
|
||||
expect(iffyScanBody).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mark workflow step as safe if no reminder body", async () => {
|
||||
|
||||
@@ -19,21 +19,6 @@ const log = logger.getSubLogger({ prefix: ["[tasker] scanWorkflowBody"] });
|
||||
export async function scanWorkflowBody(payload: string) {
|
||||
const { workflowStepIds, userId } = scanWorkflowBodySchema.parse(JSON.parse(payload));
|
||||
|
||||
if (!process.env.IFFY_API_KEY) {
|
||||
log.info("IFFY_API_KEY not set, skipping scan");
|
||||
await prisma.workflowStep.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowSteps = await prisma.workflowStep.findMany({
|
||||
where: {
|
||||
id: {
|
||||
@@ -55,68 +40,84 @@ export async function scanWorkflowBody(payload: string) {
|
||||
},
|
||||
});
|
||||
|
||||
for (const workflowStep of workflowSteps) {
|
||||
if (!workflowStep.reminderBody) {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeFormat = getTimeFormatStringFromUserTimeFormat(workflowStep.workflow.user?.timeFormat);
|
||||
|
||||
// Determine if body is a template
|
||||
const defaultTemplate = getTemplateBodyForAction({
|
||||
action: workflowStep.action,
|
||||
locale: workflowStep.workflow.user?.locale ?? "en",
|
||||
t: await getTranslation(workflowStep.workflow.user?.locale ?? "en", "common"),
|
||||
template: workflowStep.template,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
if (!defaultTemplate) {
|
||||
log.error(`Template not found for action ${workflowStep.action}, template ${workflowStep.template}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
compareReminderBodyToTemplate({ reminderBody: workflowStep.reminderBody, template: defaultTemplate })
|
||||
) {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSpam = await iffyScanBody(workflowStep.reminderBody, workflowStep.id);
|
||||
|
||||
if (isSpam) {
|
||||
if (!workflowStep.workflow.user?.whitelistWorkflows) {
|
||||
// We won't delete the workflow step incase it is flagged as a false positive
|
||||
log.warn(`Workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`);
|
||||
await lockUser("userId", userId.toString(), LockReason.SPAM_WORKFLOW_BODY);
|
||||
|
||||
// Return early if spam is detected
|
||||
return;
|
||||
if (process.env.IFFY_API_KEY) {
|
||||
for (const workflowStep of workflowSteps) {
|
||||
if (!workflowStep.reminderBody) {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
log.warn(
|
||||
`For whitelisted user, workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.workflowStep.update({
|
||||
const timeFormat = getTimeFormatStringFromUserTimeFormat(workflowStep.workflow.user?.timeFormat);
|
||||
|
||||
// Determine if body is a template
|
||||
const defaultTemplate = getTemplateBodyForAction({
|
||||
action: workflowStep.action,
|
||||
locale: workflowStep.workflow.user?.locale ?? "en",
|
||||
t: await getTranslation(workflowStep.workflow.user?.locale ?? "en", "common"),
|
||||
template: workflowStep.template,
|
||||
timeFormat,
|
||||
});
|
||||
|
||||
if (!defaultTemplate) {
|
||||
log.error(`Template not found for action ${workflowStep.action}, template ${workflowStep.template}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
compareReminderBodyToTemplate({ reminderBody: workflowStep.reminderBody, template: defaultTemplate })
|
||||
) {
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSpam = await iffyScanBody(workflowStep.reminderBody, workflowStep.id);
|
||||
|
||||
if (isSpam) {
|
||||
if (!workflowStep.workflow.user?.whitelistWorkflows) {
|
||||
// We won't delete the workflow step incase it is flagged as a false positive
|
||||
log.warn(`Workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`);
|
||||
await lockUser("userId", userId.toString(), LockReason.SPAM_WORKFLOW_BODY);
|
||||
|
||||
// Return early if spam is detected
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
`For whitelisted user, workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.workflowStep.update({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.IFFY_API_KEY) {
|
||||
log.info("IFFY_API_KEY not set, skipping scan");
|
||||
await prisma.workflowStep.updateMany({
|
||||
where: {
|
||||
id: workflowStep.id,
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
verifiedAt: new Date(),
|
||||
@@ -159,7 +160,7 @@ export async function scanWorkflowBody(payload: string) {
|
||||
});
|
||||
}
|
||||
|
||||
const iffyScanBody = async (body: string, workflowStepId: number) => {
|
||||
export const iffyScanBody = async (body: string, workflowStepId: number) => {
|
||||
try {
|
||||
const response = await fetch("https://api.iffy.com/api/v1/moderate", {
|
||||
method: "POST",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -105,15 +106,22 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState
|
||||
}
|
||||
}
|
||||
|
||||
export function UserListTable() {
|
||||
export type UserListTableProps = {
|
||||
org: RouterOutputs["viewer"]["organizations"]["listCurrent"];
|
||||
teams: RouterOutputs["viewer"]["organizations"]["getTeams"];
|
||||
facetedTeamValues: RouterOutputs["viewer"]["organizations"]["getFacetedValues"];
|
||||
attributes: RouterOutputs["viewer"]["attributes"]["list"];
|
||||
};
|
||||
|
||||
export function UserListTable(props: UserListTableProps) {
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments} defaultPageSize={25}>
|
||||
<UserListTableContent />
|
||||
<UserListTableContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UserListTableContent() {
|
||||
function UserListTableContent({ org, attributes, teams, facetedTeamValues }: UserListTableProps) {
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const orgBranding = useOrgBranding();
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
@@ -121,22 +129,6 @@ function UserListTableContent() {
|
||||
|
||||
const { data: session } = useSession();
|
||||
const { isPlatformUser } = useGetUserAttributes();
|
||||
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const { data: attributes, isSuccess: isSuccessAttributes } = trpc.viewer.attributes.list.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
@@ -274,7 +266,7 @@ function UserListTableContent() {
|
||||
enableHiding: false,
|
||||
enableColumnFilter: false,
|
||||
size: 200,
|
||||
header: "Members",
|
||||
header: t("members"),
|
||||
cell: ({ row }) => {
|
||||
const { username, email, avatarUrl } = row.original;
|
||||
return (
|
||||
@@ -305,7 +297,7 @@ function UserListTableContent() {
|
||||
{
|
||||
id: "role",
|
||||
accessorFn: (data) => data.role,
|
||||
header: "Role",
|
||||
header: t("role"),
|
||||
size: 100,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.MULTI_SELECT },
|
||||
@@ -327,7 +319,7 @@ function UserListTableContent() {
|
||||
{
|
||||
id: "teams",
|
||||
accessorFn: (data) => data.teams.map((team) => team.name),
|
||||
header: "Teams",
|
||||
header: t("teams"),
|
||||
size: 140,
|
||||
meta: {
|
||||
filter: { type: ColumnFilterType.MULTI_SELECT },
|
||||
@@ -346,7 +338,7 @@ function UserListTableContent() {
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(["PENDING"]);
|
||||
}}>
|
||||
Pending
|
||||
{t("pending")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -561,11 +553,6 @@ function UserListTableContent() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isPlatformUser && !isSuccessAttributes) {
|
||||
// do not render the table until the attributes are fetched
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableWrapper<UserTableUser>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { DataTableSkeleton } from "@calcom/features/data-table";
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export function UserListTableSkeleton() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
{/* Toolbar with search, filters, etc. */}
|
||||
<div className="grid w-full items-center gap-2 pb-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search bar */}
|
||||
<div className="border-default bg-default flex h-9 w-52 items-center gap-1 rounded-md border px-3 py-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<SkeletonText className="h-4 w-4" />
|
||||
</div>
|
||||
<SkeletonText className="h-4 w-40" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<SkeletonButton className="h-9 w-28" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SkeletonButton className="h-9 w-20" />
|
||||
<SkeletonButton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonButton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DataTable */}
|
||||
<DataTableSkeleton columns={7} rows={10} columnWidths={[30, 200, 100, 140, 120, 120, 80]} />
|
||||
|
||||
{/* CTA actions at the bottom */}
|
||||
<div className={`mt-4 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`}>
|
||||
<SkeletonButton className="h-9 w-32" />
|
||||
<SkeletonButton className="h-9 w-24" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type GetOptions = {
|
||||
eventTypeIds?: number[] | undefined;
|
||||
attendeeEmail?: string | TextFilterValue;
|
||||
attendeeName?: string | TextFilterValue;
|
||||
bookingUid?: string | undefined;
|
||||
};
|
||||
sort?: SortOptions;
|
||||
};
|
||||
|
||||
@@ -52,7 +52,9 @@ export const getCalendarsEventsWithTimezones = async (
|
||||
if (!isADelegationCredential) {
|
||||
// It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected.
|
||||
// https://github.com/calcom/cal.com/issues/8929
|
||||
log.error("No selected calendars for non DWD credential: Skipping getAvailability call");
|
||||
log.error(
|
||||
`No selected calendars for non DWD credential: Skipping getAvailability call for credential ${credential?.id}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
// For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members
|
||||
@@ -118,7 +120,9 @@ const getCalendarsEvents = async (
|
||||
if (!isADelegationCredential) {
|
||||
// It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected.
|
||||
// https://github.com/calcom/cal.com/issues/8929
|
||||
log.error("No selected calendars for non DWD credential: Skipping getAvailability call");
|
||||
log.error(
|
||||
`No selected calendars for non DWD credential: Skipping getAvailability call for credential ${credential?.id}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
// For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("@calcom/lib/server/serviceAccountKey", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
decryptServiceAccountKey: vi.fn((input) => {
|
||||
// If input is a string, parse it; otherwise, return as is
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import prisma, { type PrismaTransaction } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { CreditType } from "@calcom/prisma/enums";
|
||||
|
||||
export class CreditsRepository {
|
||||
static async findCreditBalance({ teamId, userId }: { teamId?: number; userId?: number }) {
|
||||
static async findCreditBalance(
|
||||
{ teamId, userId }: { teamId?: number; userId?: number },
|
||||
tx?: PrismaTransaction
|
||||
) {
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
const select = {
|
||||
id: true,
|
||||
additionalCredits: true,
|
||||
@@ -13,7 +18,7 @@ export class CreditsRepository {
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
return await prisma.creditBalance.findUnique({
|
||||
return await prismaClient.creditBalance.findUnique({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
@@ -22,20 +27,25 @@ export class CreditsRepository {
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
return await prisma.creditBalance.findUnique({
|
||||
return await prismaClient.creditBalance.findUnique({
|
||||
where: { userId },
|
||||
select,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async findCreditBalanceWithTeamOrUser({
|
||||
teamId,
|
||||
userId,
|
||||
}: {
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
}) {
|
||||
static async findCreditBalanceWithTeamOrUser(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
}: {
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
},
|
||||
tx?: PrismaTransaction
|
||||
) {
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
const select = {
|
||||
id: true,
|
||||
additionalCredits: true,
|
||||
@@ -69,7 +79,7 @@ export class CreditsRepository {
|
||||
},
|
||||
};
|
||||
if (teamId) {
|
||||
return await prisma.creditBalance.findUnique({
|
||||
return await prismaClient.creditBalance.findUnique({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
@@ -78,15 +88,17 @@ export class CreditsRepository {
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
return await prisma.creditBalance.findUnique({
|
||||
return await prismaClient.creditBalance.findUnique({
|
||||
where: { userId },
|
||||
select,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }) {
|
||||
return await prisma.creditBalance.findUnique({
|
||||
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }, tx?: PrismaTransaction) {
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
return await prismaClient.creditBalance.findUnique({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
@@ -109,33 +121,37 @@ export class CreditsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async updateCreditBalance({
|
||||
id,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
}: {
|
||||
id?: string;
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
data: Prisma.CreditBalanceUncheckedUpdateInput;
|
||||
}) {
|
||||
static async updateCreditBalance(
|
||||
{
|
||||
id,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
}: {
|
||||
id?: string;
|
||||
teamId?: number | null;
|
||||
userId?: number | null;
|
||||
data: Prisma.CreditBalanceUncheckedUpdateInput;
|
||||
},
|
||||
tx?: PrismaTransaction
|
||||
) {
|
||||
const prismaClient = tx ?? prisma;
|
||||
if (id) {
|
||||
return prisma.creditBalance.update({
|
||||
return prismaClient.creditBalance.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
return prisma.creditBalance.update({
|
||||
return prismaClient.creditBalance.update({
|
||||
where: { teamId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
return prisma.creditBalance.update({
|
||||
return prismaClient.creditBalance.update({
|
||||
where: { userId },
|
||||
data,
|
||||
});
|
||||
@@ -144,14 +160,17 @@ export class CreditsRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput) {
|
||||
return prisma.creditBalance.create({
|
||||
static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput, tx?: PrismaTransaction) {
|
||||
return (tx ?? prisma).creditBalance.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
static async createCreditExpenseLog(data: Prisma.CreditExpenseLogUncheckedCreateInput) {
|
||||
return prisma.creditExpenseLog.create({
|
||||
static async createCreditExpenseLog(
|
||||
data: Prisma.CreditExpenseLogUncheckedCreateInput,
|
||||
tx?: PrismaTransaction
|
||||
) {
|
||||
return (tx ?? prisma).creditExpenseLog.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user