diff --git a/apps/web/package.json b/apps/web/package.json index 8f4982fa97..4438fd41ac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -105,6 +105,7 @@ "next-seo": "^6.0.0", "next-themes": "^0.2.0", "nodemailer": "^6.7.8", + "nuqs": "^1.20.0", "otplib": "^12.0.1", "qrcode": "^1.5.1", "raw-body": "^2.5.1", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 2fbd357631..3f94fae70a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2637,5 +2637,6 @@ "month_to_date": "month to date", "year_to_date": "year to date", "custom_range": "custom range", + "mass_assign_attributes": "Mass assign attributes", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx new file mode 100644 index 0000000000..209dd13efe --- /dev/null +++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx @@ -0,0 +1,279 @@ +import type { Table } from "@tanstack/react-table"; +import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs"; +import { useState } from "react"; + +import classNames from "@calcom/lib/classNames"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { + Alert, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Icon, + Input, + Popover, + PopoverContent, + PopoverTrigger, + showToast, +} from "@calcom/ui"; + +import type { User } from "../UserListTable"; + +interface Props { + table: Table; +} + +function useSelectedAttributes() { + const [selectedAttribute, setSelectedAttribute] = useQueryState("a", parseAsString); + const utils = trpc.useUtils(); + const attributeData = utils.viewer.attributes.list.getData(); + const foundAttribute = attributeData?.find((attr) => attr.id === selectedAttribute); + + return { + selectedAttribute, + setSelectedAttribute, + foundAttributeInCache: foundAttribute, + }; +} + +function useSelectedAttributeOption() { + return useQueryState("ao", parseAsArrayOf(parseAsString).withDefault([])); +} + +function SelectedAttributeToAssign() { + const { t } = useLocale(); + const [selectedAttributeOption, setSelectedAttributeOption] = useSelectedAttributeOption(); + const { selectedAttribute, setSelectedAttribute } = useSelectedAttributes(); + const utils = trpc.useUtils(); + const attributeData = utils.viewer.attributes.list.getData(); + const foundAttribute = attributeData?.find((attr) => attr.id === selectedAttribute); + + if (!foundAttribute) { + setSelectedAttribute(null); + return null; + } + + function getTranslateableStringFromType(type: string) { + switch (type) { + case "SINGLE_SELECT": + return "single_select"; + case "MULTI_SELECT": + return "multi_select"; + case "TEXT": + return "text"; + case "NUMBER": + return "number"; + default: + return undefined; + } + } + const translateableType = getTranslateableStringFromType(foundAttribute.type); + + const isSelectable = foundAttribute.type === "SINGLE_SELECT" || foundAttribute.type === "MULTI_SELECT"; + + return ( + +
+ {foundAttribute.name} + {translateableType && ({t(translateableType)})} +
+ + {isSelectable ? ( + <> + {foundAttribute.options.map((option) => { + return ( + { + if (foundAttribute.type === "SINGLE_SELECT") { + setSelectedAttributeOption([option.id]); + } else { + setSelectedAttributeOption((prev) => { + if (prev.includes(option.id)) { + return prev.filter((id) => id !== option.id); + } + return [...prev, option.id]; + }); + } + }}> + {option.value} +
+ {selectedAttributeOption?.includes(option.id) ? ( + + ) : null} +
+
+ ); + })} + + ) : ( + <> + + { + setSelectedAttributeOption([e.target.value]); + }} + /> + + + )} +
+
+ ); +} + +export function MassAssignAttributesBulkAction({ table }: Props) { + const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes(); + const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption(); + const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false); + const { t } = useLocale(); + const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({ + onSuccess: (success) => { + showToast(success.message, "success"); + }, + onError: (error) => { + showToast(`Error assigning attributes: ${error.message}`, "error"); + }, + }); + const { data } = trpc.viewer.attributes.list.useQuery(); + + function Content() { + if (!selectedAttribute) { + return ( + <> + + + No attributes found + + {data && + data.map((option) => { + return ( + { + setSelectedAttribute(option.id); + }}> + {option.name} +
+ +
+
+ ); + })} +
+
+ + ); + } + + if (showMultiSelectWarning) { + return ( +
+ +
+ ); + } + + if (selectedAttribute) { + return ; + } + + return null; + } + + return ( + <> + + + + + {/* We dont really use shadows much - but its needed here */} + + + + +
+ {selectedAttribute ? ( + <> + + + + ) : null} +
+
+
+ + ); +} diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index a5d553a5ed..c0cfa00299 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -15,6 +15,7 @@ import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui"; import { useOrgBranding } from "../../../ee/organizations/context/provider"; import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers"; import { EventTypesList } from "./BulkActions/EventTypesList"; +import { MassAssignAttributesBulkAction } from "./BulkActions/MassAssignAttributes"; import { TeamListBulkAction } from "./BulkActions/TeamList"; import { ChangeUserRoleModal } from "./ChangeUserRoleModal"; import { DeleteMemberModal } from "./DeleteMemberModal"; @@ -343,6 +344,10 @@ export function UserListTable() { type: "render", render: (table) => , }, + { + type: "render", + render: (table) => , + }, { type: "action", icon: "handshake", diff --git a/packages/prisma/migrations/20241008124232_remove_attribute_value_unique/migration.sql b/packages/prisma/migrations/20241008124232_remove_attribute_value_unique/migration.sql new file mode 100644 index 0000000000..5b3df43210 --- /dev/null +++ b/packages/prisma/migrations/20241008124232_remove_attribute_value_unique/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "AttributeOption_attributeId_value_key"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 5ad99c650e..b01a81168e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1529,8 +1529,6 @@ model AttributeOption { value String slug String assignedUsers AttributeToUser[] - - @@unique([attributeId, value]) } model Attribute { diff --git a/packages/trpc/server/routers/viewer/attributes/_router.ts b/packages/trpc/server/routers/viewer/attributes/_router.ts index 169fbc255e..84e847595d 100644 --- a/packages/trpc/server/routers/viewer/attributes/_router.ts +++ b/packages/trpc/server/routers/viewer/attributes/_router.ts @@ -1,6 +1,7 @@ import authedProcedure, { authedOrgAdminProcedure } from "../../../procedures/authedProcedure"; import { router, importHandler } from "../../../trpc"; import { assignUserToAttributeSchema } from "./assignUserToAttribute.schema"; +import { bulkAssignAttributesSchema } from "./bulkAssignAttributes.schema"; import { createAttributeSchema } from "./create.schema"; import { deleteAttributeSchema } from "./delete.schema"; import { editAttributeSchema } from "./edit.schema"; @@ -52,4 +53,14 @@ export const attributesRouter = router({ ); return handler({ ctx, input }); }), + + bulkAssignAttributes: authedOrgAdminProcedure + .input(bulkAssignAttributesSchema) + .mutation(async ({ ctx, input }) => { + const handler = await importHandler( + namespaced("bulkAssignAttributes"), + () => import("./bulkAssignAttributes.handler") + ); + return handler({ ctx, input }); + }), }); diff --git a/packages/trpc/server/routers/viewer/attributes/bulkAssignAttributes.handler.ts b/packages/trpc/server/routers/viewer/attributes/bulkAssignAttributes.handler.ts new file mode 100644 index 0000000000..5a4384551a --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/bulkAssignAttributes.handler.ts @@ -0,0 +1,205 @@ +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { ZBulkAssignAttributes } from "./bulkAssignAttributes.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: ZBulkAssignAttributes; +}; + +const bulkAssignAttributesHandler = async ({ input, ctx }: GetOptions) => { + const org = ctx.user.organization; + + if (!org.id) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to be part of an organization to use this feature", + }); + } + + // Ensure this organization can access these attributes and attribute options + const attributes = await prisma.attribute.findMany({ + where: { + id: { + in: input.attributes.map((attribute) => attribute.id), + }, + teamId: org.id, + }, + select: { + id: true, + type: true, + options: true, + }, + }); + + if (attributes.length !== input.attributes.length) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You do not have access to these attributes", + }); + } + + const arrayOfAttributeOptionIds = attributes.flatMap( + (attribute) => attribute.options?.map((option) => option.id) || [] + ); + + const attributeOptionIds = Array.from(new Set(arrayOfAttributeOptionIds)); + + const attributeOptions = await prisma.attributeOption.findMany({ + where: { + id: { + in: attributeOptionIds, + }, + attribute: { + teamId: org.id, + }, + }, + select: { + id: true, + value: true, + slug: true, + }, + }); + + if (attributeOptions.length !== attributeOptionIds.length) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You do not have access to these attribute options", + }); + } + + const results = await Promise.all( + input.userIds.map(async (userId) => { + return prisma.$transaction(async (tx) => { + const membership = await tx.membership.findFirst({ + where: { + userId: userId, + // @ts-expect-error we check this higher in the logic + teamId: org?.id, + }, + }); + + if (!membership) { + return { + userId, + success: false, + message: "User is not part of your organization", + }; + } + + for (const attribute of input.attributes) { + // TEXT, NUMBER + if (attribute.value && !attribute.options) { + const valueAsString = String(attribute.value); + + // Check if it is already the value + const existingAttributeOption = await tx.attributeToUser.findFirst({ + where: { + memberId: membership.id, + attributeOption: { + attribute: { + id: attribute.id, + }, + }, + }, + select: { + id: true, + attributeOption: { + select: { + id: true, + }, + }, + }, + }); + + if (existingAttributeOption) { + // Update the value if it already exists + await tx.attributeOption.update({ + where: { + id: existingAttributeOption.attributeOption.id, + }, + data: { + value: valueAsString, + slug: slugify(valueAsString), + }, + }); + } else { + await tx.attributeOption.create({ + data: { + value: valueAsString, + slug: slugify(valueAsString), + attribute: { + connect: { + id: attribute.id, + }, + }, + assignedUsers: { + create: { + memberId: membership.id, + }, + }, + }, + }); + } + } else if (!attribute.value && attribute.options && attribute.options.length > 0) { + const options = attribute.options; + + for (const option of options) { + // Assign the attribute option to the user + await tx.attributeToUser.upsert({ + where: { + memberId_attributeOptionId: { + memberId: membership.id, + attributeOptionId: option.value, + }, + }, + create: { + memberId: membership.id, + attributeOptionId: option.value, + }, + update: {}, // No update needed if it already exists + }); + } + } + + // Delete the attribute from the user + if (!attribute.value && !attribute.options) { + await tx.attributeToUser.deleteMany({ + where: { + memberId: membership.id, + attributeOption: { + attribute: { + id: attribute.id, + }, + }, + }, + }); + } + } + + return { + userId, + success: true, + message: "Attributes assigned successfully", + }; + }); + }) + ); + + const successCount = results.filter((result) => result.success).length; + const failureCount = results.length - successCount; + + return { + success: true, + message: `Attributes assigned successfully for ${successCount} users. Failed for ${failureCount} users.`, + results, + }; +}; + +export default bulkAssignAttributesHandler; diff --git a/packages/trpc/server/routers/viewer/attributes/bulkAssignAttributes.schema.ts b/packages/trpc/server/routers/viewer/attributes/bulkAssignAttributes.schema.ts new file mode 100644 index 0000000000..b386ba9764 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/bulkAssignAttributes.schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const attributeSchema = z.object({ + id: z.string(), + options: z.array(z.object({ label: z.string().optional(), value: z.string() })).optional(), + value: z.string().optional(), +}); + +export const bulkAssignAttributesSchema = z.object({ + userIds: z.number().array(), + attributes: attributeSchema.array(), +}); + +export type ZBulkAssignAttributes = z.infer;