Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2845,6 +2845,7 @@
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
"booking_limits": "Booking Limits",
"booking_limits_team_description": "Booking limits for team members across all team event types",
"booking_limits_member_description": "Set booking limits for this member across all their event types",
"limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types",
"booking_limits_updated_successfully": "Booking limits updated successfully",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/lib/generateCsv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe("generate Csv for Org Users Table", () => {
lastActiveAt: new Date().toISOString(),
createdAt: null,
updatedAt: null,
bookingLimits: null,
customRole: {
type: "SYSTEM",
id: "member_role",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { TRPCError } from "@trpc/server";
import { createFallbackRoute } from "../lib/createFallbackRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
import isRouter from "../lib/isRouter";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm } from "../types/types";
Expand Down
2 changes: 0 additions & 2 deletions packages/app-store/routing-forms/trpc/formQuery.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";

import { TRPCError } from "@trpc/server";

import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFormQueryInputSchema } from "./formQuery.schema";
import { checkPermissionOnExistingRoutingForm } from "./permissions";
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/routing-forms/trpc/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import type { MembershipRole } from "@calcom/prisma/enums";

import { TRPCError } from "@trpc/server";

Expand Down
30 changes: 29 additions & 1 deletion packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ import {
enrichHostsWithDelegationCredentials,
getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/lib/delegationCredential/server";
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/BookingLimits";
import {
getCheckBookingAndDurationLimitsService,
getCheckBookingLimitsService,
} from "@calcom/lib/di/containers/BookingLimits";
import { getCacheService } from "@calcom/lib/di/containers/Cache";
import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser";
import { ErrorCode } from "@calcom/lib/errorCodes";
Expand All @@ -64,6 +67,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import logger from "@calcom/lib/logger";
import { handlePayment } from "@calcom/lib/payment/handlePayment";
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
Expand Down Expand Up @@ -1032,6 +1036,30 @@ async function handler(
? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0]
: users[0];

if (eventType.team?.id) {
const organizerMembership = await prisma.membership.findFirst({
where: {
userId: organizerUser.id,
teamId: eventType.team.id,
accepted: true,
},
select: {
bookingLimits: true,
},
});

if (organizerMembership?.bookingLimits) {
const checkBookingLimitsService = getCheckBookingLimitsService();
await checkBookingLimitsService.checkBookingLimits(
organizerMembership.bookingLimits as IntervalLimit,
dayjs(reqBody.start).toDate(),
eventType.id,
reqBody.rescheduleUid,
organizerUser.timeZone
);
}
}

const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(organizerUser, eventType);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { Controller, useForm, useFormContext } from "react-hook-form";
import { z } from "zod";

import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import { IntervalLimitsManager } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { emailSchema } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { intervalLimitsType, type IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
Expand Down Expand Up @@ -62,6 +64,7 @@ const editSchema = z.object({
role: z.union([z.nativeEnum(MembershipRole), z.string()]),
timeZone: timeZoneSchema,
attributes: z.array(attributeSchema).optional(),
bookingLimits: intervalLimitsType.optional(),
});

type EditSchema = z.infer<typeof editSchema>;
Expand Down Expand Up @@ -93,6 +96,7 @@ export function EditForm({
bio: selectedUser?.bio ?? "",
role: selectedUser?.role ?? "",
timeZone: selectedUser?.timeZone ?? "",
bookingLimits: selectedUser?.bookingLimits as IntervalLimit | undefined,
},
});

Expand Down Expand Up @@ -184,6 +188,7 @@ export function EditForm({
attributeOptions: values.attributes
? { userId: selectedUser?.id ?? "", attributes: values.attributes }
: undefined,
bookingLimits: values.bookingLimits,
});
setEditMode(false);
}}>
Expand Down Expand Up @@ -251,6 +256,17 @@ export function EditForm({
</div>
<Divider />
<AttributesList selectedUserId={selectedUser?.id} />

<div className="mt-8">
<Label className="text-emphasis font-medium">{t("booking_limits")}</Label>
<p className="text-default mb-2 text-sm">{t("booking_limits_member_description")}</p>
<IntervalLimitsManager
propertyName="bookingLimits"
defaultLimit={1}
step={1}
textFieldSuffix={t("bookings")}
/>
</div>
</SheetBody>
<SheetFooter>
<Button
Expand Down
41 changes: 41 additions & 0 deletions packages/lib/intervalLimits/validateIntervalLimitOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";

import { validateIntervalLimitOrder } from "./validateIntervalLimitOrder";

describe("validateIntervalLimitOrder", () => {
it("should return true for valid ascending order", () => {
const validLimits = {
PER_DAY: 1,
PER_WEEK: 5,
PER_MONTH: 20,
PER_YEAR: 100,
};
expect(validateIntervalLimitOrder(validLimits)).toBe(true);
});

it("should return false for invalid descending order", () => {
const invalidLimits = {
PER_DAY: 10,
PER_WEEK: 5,
PER_MONTH: 20,
PER_YEAR: 100,
};
expect(validateIntervalLimitOrder(invalidLimits)).toBe(false);
});

it("should return true for partial valid limits", () => {
const partialLimits = {
PER_DAY: 2,
PER_MONTH: 10,
};
expect(validateIntervalLimitOrder(partialLimits)).toBe(true);
});

it("should return false for partial invalid limits", () => {
const partialInvalidLimits = {
PER_WEEK: 10,
PER_DAY: 15,
};
expect(validateIntervalLimitOrder(partialInvalidLimits)).toBe(false);
});
});
20 changes: 12 additions & 8 deletions packages/lib/intervalLimits/validateIntervalLimitOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { ascendingLimitKeys } from "./intervalLimit";
import type { IntervalLimit } from "./intervalLimitSchema";

export const validateIntervalLimitOrder = (input: IntervalLimit) => {
// Sort limits by validationOrder
const sorted = Object.entries(input)
.sort(([, value], [, valuetwo]) => {
return value - valuetwo;
})
.map(([key]) => key);
const inputKeys = Object.keys(input);

const validationOrderWithoutMissing = ascendingLimitKeys.filter((key) => sorted.includes(key));
const relevantKeys = ascendingLimitKeys.filter((key) => inputKeys.includes(key));

return sorted.every((key, index) => validationOrderWithoutMissing[index] === key);
for (let i = 0; i < relevantKeys.length - 1; i++) {
const currentKey = relevantKeys[i];
const nextKey = relevantKeys[i + 1];

if (input[currentKey] > input[nextKey]) {
return false;
}
}

return true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "bookingLimits" JSONB;
1 change: 1 addition & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ model Membership {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
bookingLimits Json?
AttributeToUser AttributeToUser[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export async function getUserHandler({ input, ctx }: AdminVerifyOptions) {
},
select: {
role: true,
bookingLimits: true,
},
}),
prisma.membership.findMany({
Expand Down Expand Up @@ -89,6 +90,7 @@ export async function getUserHandler({ input, ctx }: AdminVerifyOptions) {
accepted: team.accepted,
})),
role: membership.role,
bookingLimits: membership.bookingLimits,
};

return foundUser;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
createdAt: true,
updatedAt: true,
customRole: true,
bookingLimits: true,
user: {
select: {
id: true,
Expand Down Expand Up @@ -313,6 +314,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
: null,
avatarUrl: user.avatarUrl,
...(ctx.user.organization.isOrgAdmin && { twoFactorEnabled: user.twoFactorEnabled }),
bookingLimits: membership.bookingLimits,
teams: user.teams
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team
.map((team) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensur
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { RoleManagementError } from "@calcom/features/pbac/domain/errors/role-management.error";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
Expand Down Expand Up @@ -171,6 +172,26 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
});
}

if (input.bookingLimits !== undefined) {
if (input.bookingLimits) {
const isValid = validateIntervalLimitOrder(input.bookingLimits);
if (!isValid)
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking limits must be in ascending order." });
}

await prisma.membership.update({
where: {
userId_teamId: {
userId: input.userId,
teamId: organizationId,
},
},
data: {
bookingLimits: input.bookingLimits ? JSON.parse(JSON.stringify(input.bookingLimits)) : null,
},
});
}

// We cast to membership role as we know pbac insnt enabled on this instance.
if (checkAdminOrOwner(input.role as MembershipRole) && roleManager.isPBACEnabled) {
const teamIds = requestedMember.team.children
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";

import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { intervalLimitsType } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { MembershipRole } from "@calcom/prisma/enums";

import { assignUserToAttributeSchema } from "../attributes/assignUserToAttribute.schema";
Expand All @@ -18,6 +19,7 @@ export const ZUpdateUserInputSchema = z.object({
),
timeZone: timeZoneSchema,
attributeOptions: assignUserToAttributeSchema.optional(),
bookingLimits: intervalLimitsType.optional(),
});

export type TUpdateUserInputSchema = z.infer<typeof ZUpdateUserInputSchema>;
Loading