From bad41f212ec963a299a362023422930ad96ad04d Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 24 Jan 2025 10:41:15 +0100 Subject: [PATCH 01/11] feat(import-users): create client-side dialog for importing users --- .../settings/import-users-dialog.tsx | 144 ++++++++++++++++++ app/routes/_layout+/settings.team.users.tsx | 4 +- 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 app/components/settings/import-users-dialog.tsx diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx new file mode 100644 index 000000000..bf1493bdf --- /dev/null +++ b/app/components/settings/import-users-dialog.tsx @@ -0,0 +1,144 @@ +import { cloneElement, useState } from "react"; +import { useFetcher } from "@remix-run/react"; +import { UploadIcon } from "lucide-react"; +import { tw } from "~/utils/tw"; +import Input from "../forms/input"; +import { Dialog, DialogPortal } from "../layout/dialog"; +import { Button } from "../shared/button"; +import { WarningBox } from "../shared/warning-box"; + +type ImportUsersDialogProps = { + className?: string; + trigger?: React.ReactElement<{ onClick: () => void }>; +}; + +export default function ImportUsersDialog({ + className, + trigger, +}: ImportUsersDialogProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(); + const [error, setError] = useState(""); + + const fetcher = useFetcher(); + + function openDialog() { + setIsDialogOpen(true); + } + + function closeDialog() { + setIsDialogOpen(false); + } + + function handleSelectFile(event: React.ChangeEvent) { + setError(""); + + const file = event.target.files?.[0]; + if (file?.type !== "text/csv") { + setError("Invalid file type. Please select a CSV file."); + return; + } + + setSelectedFile(file); + } + + return ( + <> + {trigger ? ( + cloneElement(trigger, { onClick: openDialog }) + ) : ( + + )} + + + + + + } + > +
+

Invite Users via CSV Upload

+

+ Invite multiple users to your organization by uploading a CSV + file. To get started,{" "} + +

+ + <> + IMPORTANT: Use the provided template to ensure + proper formatting. Uploading incorrectly formatted files may + cause errors. + + +

Base Rules and Limitations

+
    +
  • + You must use , (comma) as a delimiter in your CSV file. +
  • +
  • + Each row represents a new user to be invited. Ensure the email + column is valid. +
  • +
  • + Invited users will receive an email with a link to join the + organization. +
  • +
+ +

Extra Considerations

+
+
    +
  • + The first row of the sheet will be ignored. Use it for column + headers as in the provided template. +
  • +
  • + If any of the rows contain invalid data, they will be skipped, + but the valid rows will still be processed. +
  • +
+
+ +

+ Once you've uploaded your file, a summary of the processed + invitations will be displayed, along with any errors encountered. +

+ + + + + + +
+
+
+ + ); +} diff --git a/app/routes/_layout+/settings.team.users.tsx b/app/routes/_layout+/settings.team.users.tsx index a629342d4..d7009075d 100644 --- a/app/routes/_layout+/settings.team.users.tsx +++ b/app/routes/_layout+/settings.team.users.tsx @@ -11,6 +11,7 @@ import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { ListContentWrapper } from "~/components/list/content-wrapper"; import { Filters } from "~/components/list/filters"; +import ImportUsersDialog from "~/components/settings/import-users-dialog"; import InviteUserDialog from "~/components/settings/invite-user-dialog"; import { Button } from "~/components/shared/button"; import { InfoTooltip } from "~/components/shared/info-tooltip"; @@ -160,10 +161,11 @@ export default function UserTeamSetting() { + Invite a user From e4f40e34359e519b8274e1851ff14cb62bcb451c Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 24 Jan 2025 12:56:32 +0100 Subject: [PATCH 02/11] feat(import-users): create backend logic for import users --- .../settings/import-users-dialog.tsx | 29 ++++++- .../settings/invite-user-dialog.tsx | 1 - app/modules/invite/service.server.ts | 53 +++++++++++- app/routes/api+/settings.import-users.ts | 86 +++++++++++++++++++ ...f.nu-example-import-users-from-content.csv | 2 + 5 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 app/routes/api+/settings.import-users.ts create mode 100644 public/static/shelf.nu-example-import-users-from-content.csv diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx index bf1493bdf..e78dffdfb 100644 --- a/app/components/settings/import-users-dialog.tsx +++ b/app/components/settings/import-users-dialog.tsx @@ -1,11 +1,13 @@ import { cloneElement, useState } from "react"; import { useFetcher } from "@remix-run/react"; import { UploadIcon } from "lucide-react"; +import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; import Input from "../forms/input"; import { Dialog, DialogPortal } from "../layout/dialog"; import { Button } from "../shared/button"; import { WarningBox } from "../shared/warning-box"; +import When from "../when/when"; type ImportUsersDialogProps = { className?: string; @@ -20,7 +22,11 @@ export default function ImportUsersDialog({ const [selectedFile, setSelectedFile] = useState(); const [error, setError] = useState(""); - const fetcher = useFetcher(); + const fetcher = useFetcher<{ + error?: { message?: string }; + success?: boolean; + }>(); + const disabled = isFormProcessing(fetcher.state); function openDialog() { setIsDialogOpen(true); @@ -74,7 +80,7 @@ export default function ImportUsersDialog({ file. To get started,{" "} + +

+ {fetcher.data?.error?.message} +

+
+ + diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx index 4ef96c4b9..ca3bc5820 100644 --- a/app/components/settings/invite-user-dialog.tsx +++ b/app/components/settings/invite-user-dialog.tsx @@ -121,7 +121,6 @@ export default function InviteUserDialog({ method="post" className="flex flex-col gap-3" > - {/* */} []; + userId: User["id"]; + organizationId: Organization["id"]; +}) { + try { + for (const user of users) { + const existingInvite = await db.invite.count({ + where: { + status: "PENDING", + inviteeEmail: user.email, + organizationId, + }, + }); + + /** If user is already invited, then we do not invite him/her again. */ + if (existingInvite) { + continue; + } + + await createInvite({ + organizationId, + inviteeEmail: user.email, + inviterId: userId, + roles: [user.role], + teamMemberName: user.email.split("@")[0], + userId, + }); + } + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while inviting users.", + label, + additionalData: { users }, + }); + } +} diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts new file mode 100644 index 000000000..e950d6cd4 --- /dev/null +++ b/app/routes/api+/settings.import-users.ts @@ -0,0 +1,86 @@ +import { OrganizationRoles } from "@prisma/client"; +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { bulkInviteUsers } from "~/modules/team-member/service.server"; +import { csvDataFromRequest } from "~/utils/csv.server"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { makeShelfError, ShelfError } from "~/utils/error"; +import { assertIsPost, data, error } from "~/utils/http.server"; +import { extractCSVDataFromContentImport } from "~/utils/import.server"; +import { validEmail } from "~/utils/misc"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; +import { assertUserCanInviteUsersToWorkspace } from "~/utils/subscription.server"; + +export const importUsersSchema = z.object({ + role: z.preprocess( + (value) => String(value).trim().toUpperCase(), + z.enum([ + OrganizationRoles.ADMIN, + OrganizationRoles.BASE, + OrganizationRoles.SELF_SERVICE, + ]) + ), + email: z + .string() + .transform((email) => email.toLowerCase()) + .refine(validEmail, () => ({ + message: "Please enter a valid email", + })), +}); + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + assertIsPost(request); + + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.teamMember, + action: PermissionAction.create, + }); + + await assertUserCanInviteUsersToWorkspace({ organizationId }); + + const csvData = await csvDataFromRequest({ request }); + if (csvData.length < 2) { + throw new ShelfError({ + cause: null, + message: "CSV file is empty", + additionalData: { userId }, + label: "Team Member", + shouldBeCaptured: false, + }); + } + + const users = extractCSVDataFromContentImport( + csvData, + importUsersSchema.array() + ); + + await bulkInviteUsers({ + organizationId, + userId, + users, + }); + + sendNotification({ + title: "Successfully invited users", + message: + "They will receive an email in which they can complete their registration.", + icon: { name: "success", variant: "success" }, + senderId: userId, + }); + + return json(data({ success: true })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} diff --git a/public/static/shelf.nu-example-import-users-from-content.csv b/public/static/shelf.nu-example-import-users-from-content.csv new file mode 100644 index 000000000..dbe779696 --- /dev/null +++ b/public/static/shelf.nu-example-import-users-from-content.csv @@ -0,0 +1,2 @@ +role,email +ADMIN,rohitsaini.codes+1@gmail.com \ No newline at end of file From b3628f946573b971375d278c1076ea0c3d369dde Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 24 Jan 2025 13:23:19 +0100 Subject: [PATCH 03/11] feat(import-users): fix schema for invite user --- .../settings/invite-user-dialog.tsx | 12 +++++++++- app/modules/invite/service.server.ts | 4 ++-- app/routes/api+/settings.import-users.ts | 24 +++---------------- 3 files changed, 16 insertions(+), 24 deletions(-) diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx index ca3bc5820..1cb33f76e 100644 --- a/app/components/settings/invite-user-dialog.tsx +++ b/app/components/settings/invite-user-dialog.tsx @@ -39,7 +39,17 @@ export const InviteUserFormSchema = z.object({ message: "Please enter a valid email", })), teamMemberId: z.string().optional(), - role: z.nativeEnum(OrganizationRoles, { message: "Please select a role." }), + role: z.preprocess( + (value) => String(value).trim().toUpperCase(), + z.enum( + [ + OrganizationRoles.ADMIN, + OrganizationRoles.BASE, + OrganizationRoles.SELF_SERVICE, + ], + { message: "Please select a role" } + ) + ), }); const organizationRolesMap: Record = { diff --git a/app/modules/invite/service.server.ts b/app/modules/invite/service.server.ts index 6efa51b43..993fae09e 100644 --- a/app/modules/invite/service.server.ts +++ b/app/modules/invite/service.server.ts @@ -9,11 +9,11 @@ import { InviteStatuses } from "@prisma/client"; import type { AppLoadContext, LoaderFunctionArgs } from "@remix-run/node"; import jwt from "jsonwebtoken"; import type { z } from "zod"; +import type { InviteUserFormSchema } from "~/components/settings/invite-user-dialog"; import { db } from "~/database/db.server"; import { invitationTemplateString } from "~/emails/invite-template"; import { sendEmail } from "~/emails/mail.server"; import { organizationRolesMap } from "~/routes/_layout+/settings.team"; -import type { importUsersSchema } from "~/routes/api+/settings.import-users"; import { INVITE_EXPIRY_TTL_DAYS } from "~/utils/constants"; import { updateCookieWithPerPage } from "~/utils/cookies.server"; import { sendNotification } from "~/utils/emitter/send-notification.server"; @@ -546,7 +546,7 @@ export async function bulkInviteUsers({ userId, organizationId, }: { - users: z.infer[]; + users: z.infer[]; userId: User["id"]; organizationId: Organization["id"]; }) { diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts index e950d6cd4..196027782 100644 --- a/app/routes/api+/settings.import-users.ts +++ b/app/routes/api+/settings.import-users.ts @@ -1,13 +1,12 @@ -import { OrganizationRoles } from "@prisma/client"; import { json, type ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod"; -import { bulkInviteUsers } from "~/modules/team-member/service.server"; +import { InviteUserFormSchema } from "~/components/settings/invite-user-dialog"; +import { bulkInviteUsers } from "~/modules/invite/service.server"; import { csvDataFromRequest } from "~/utils/csv.server"; import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError, ShelfError } from "~/utils/error"; import { assertIsPost, data, error } from "~/utils/http.server"; import { extractCSVDataFromContentImport } from "~/utils/import.server"; -import { validEmail } from "~/utils/misc"; import { PermissionAction, PermissionEntity, @@ -15,23 +14,6 @@ import { import { requirePermission } from "~/utils/roles.server"; import { assertUserCanInviteUsersToWorkspace } from "~/utils/subscription.server"; -export const importUsersSchema = z.object({ - role: z.preprocess( - (value) => String(value).trim().toUpperCase(), - z.enum([ - OrganizationRoles.ADMIN, - OrganizationRoles.BASE, - OrganizationRoles.SELF_SERVICE, - ]) - ), - email: z - .string() - .transform((email) => email.toLowerCase()) - .refine(validEmail, () => ({ - message: "Please enter a valid email", - })), -}); - export async function action({ context, request }: ActionFunctionArgs) { const authSession = context.getSession(); const { userId } = authSession; @@ -61,7 +43,7 @@ export async function action({ context, request }: ActionFunctionArgs) { const users = extractCSVDataFromContentImport( csvData, - importUsersSchema.array() + InviteUserFormSchema.array() ); await bulkInviteUsers({ From d69a2784363d62450dd1b89fd0375a5a408d07ac Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 24 Jan 2025 15:38:10 +0100 Subject: [PATCH 04/11] feat(import-users): close dialog on success --- .../settings/import-users-dialog.tsx | 22 ++++++++++++++----- app/routes/api+/settings.import-users.ts | 1 - 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx index e78dffdfb..8299a8b67 100644 --- a/app/components/settings/import-users-dialog.tsx +++ b/app/components/settings/import-users-dialog.tsx @@ -1,6 +1,6 @@ -import { cloneElement, useState } from "react"; -import { useFetcher } from "@remix-run/react"; +import { cloneElement, useCallback, useEffect, useState } from "react"; import { UploadIcon } from "lucide-react"; +import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; import Input from "../forms/input"; @@ -22,7 +22,7 @@ export default function ImportUsersDialog({ const [selectedFile, setSelectedFile] = useState(); const [error, setError] = useState(""); - const fetcher = useFetcher<{ + const fetcher = useFetcherWithReset<{ error?: { message?: string }; success?: boolean; }>(); @@ -32,9 +32,9 @@ export default function ImportUsersDialog({ setIsDialogOpen(true); } - function closeDialog() { + const closeDialog = useCallback(() => { setIsDialogOpen(false); - } + }, []); function handleSelectFile(event: React.ChangeEvent) { setError(""); @@ -48,13 +48,23 @@ export default function ImportUsersDialog({ setSelectedFile(file); } + useEffect( + function handleSuccess() { + if (fetcher.data?.success === true) { + closeDialog(); + fetcher.reset(); + } + }, + [closeDialog, fetcher, fetcher.data?.success] + ); + return ( <> {trigger ? ( cloneElement(trigger, { onClick: openDialog }) ) : ( )} + + { - await registerBookingWorkers().catch((cause) => { - Logger.error( - new ShelfError({ - cause, - message: "Something went wrong while registering booking workers.", - label: "Scheduler", - }) - ); - }); - }) - .finally(() => { - // eslint-disable-next-line no-console - console.log("Scheduler and workers registration completed"); - }) - .catch((cause) => { - Logger.error( - new ShelfError({ - cause, - message: "Scheduler crash", - label: "Scheduler", - }) - ); - }); +// schedulerService +// .init() +// .then(async () => { +// await registerBookingWorkers().catch((cause) => { +// Logger.error( +// new ShelfError({ +// cause, +// message: "Something went wrong while registering booking workers.", +// label: "Scheduler", +// }) +// ); +// }); +// }) +// .finally(() => { +// // eslint-disable-next-line no-console +// console.log("Scheduler and workers registration completed"); +// }) +// .catch((cause) => { +// Logger.error( +// new ShelfError({ +// cause, +// message: "Scheduler crash", +// label: "Scheduler", +// }) +// ); +// }); // === end: register scheduler and workers === /** diff --git a/app/modules/invite/service.server.ts b/app/modules/invite/service.server.ts index 993fae09e..4a0e01e70 100644 --- a/app/modules/invite/service.server.ts +++ b/app/modules/invite/service.server.ts @@ -545,10 +545,12 @@ export async function bulkInviteUsers({ users, userId, organizationId, + message, }: { users: z.infer[]; userId: User["id"]; organizationId: Organization["id"]; + message?: string | null; }) { try { for (const user of users) { diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts index e7d284c41..41217bd31 100644 --- a/app/routes/api+/settings.import-users.ts +++ b/app/routes/api+/settings.import-users.ts @@ -29,6 +29,8 @@ export async function action({ context, request }: ActionFunctionArgs) { await assertUserCanInviteUsersToWorkspace({ organizationId }); + const formData = await request.clone().formData(); + const csvData = await csvDataFromRequest({ request }); if (csvData.length < 2) { throw new ShelfError({ @@ -49,6 +51,7 @@ export async function action({ context, request }: ActionFunctionArgs) { organizationId, userId, users, + message: formData.get("message") as string, }); sendNotification({ From 24f69d70849172b02501535cd2d55e39a92c38c2 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 27 Jan 2025 11:37:03 +0100 Subject: [PATCH 06/11] feat(import-users): add message field to send in email --- app/emails/invite-template.tsx | 28 +++++++++++++++++++++++++--- app/modules/invite/helpers.ts | 10 +++++++++- app/modules/invite/service.server.ts | 7 +++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/app/emails/invite-template.tsx b/app/emails/invite-template.tsx index 6ecba906c..d7c476e31 100644 --- a/app/emails/invite-template.tsx +++ b/app/emails/invite-template.tsx @@ -15,9 +15,14 @@ import { styles } from "./styles"; interface Props { invite: InviteWithInviterAndOrg; token: string; + extraMessage?: string | null; } -export function InvitationEmailTemplate({ invite, token }: Props) { +export function InvitationEmailTemplate({ + invite, + token, + extraMessage, +}: Props) { const { emailPrimaryColor } = config; return ( @@ -50,6 +55,13 @@ export function InvitationEmailTemplate({ invite, token }: Props) { have any questions or need assistance, please don't hesitate to contact our support team at support@shelf.nu. + + {extraMessage ? ( + + {extraMessage} + + ) : null} + Thanks,
The Shelf team @@ -71,5 +83,15 @@ export function InvitationEmailTemplate({ invite, token }: Props) { *The HTML content of an email will be accessed by a server file to send email, we cannot import a TSX component in a server file so we are exporting TSX converted to HTML string using render function by react-email. */ -export const invitationTemplateString = ({ token, invite }: Props) => - render(); +export const invitationTemplateString = ({ + token, + invite, + extraMessage, +}: Props) => + render( + + ); diff --git a/app/modules/invite/helpers.ts b/app/modules/invite/helpers.ts index 1bdda8c71..4d6885810 100644 --- a/app/modules/invite/helpers.ts +++ b/app/modules/invite/helpers.ts @@ -15,12 +15,18 @@ export function generateRandomCode(length: number): string { export const inviteEmailText = ({ invite, token, + extraMessage, }: { invite: InviteWithInviterAndOrg; token: string; + extraMessage?: string | null; }) => `Howdy, -${invite.inviter.firstName} ${invite.inviter.lastName} invites you to join Shelf as a member of ${invite.organization.name}’s workspace. +${invite.inviter.firstName} ${ + invite.inviter.lastName +} invites you to join Shelf as a member of ${ + invite.organization.name +}’s workspace. Click the link to accept the invite: ${SERVER_URL}/accept-invite/${invite.id}?token=${token} @@ -29,6 +35,8 @@ Once you’re done setting up your account, you'll be able to access the workspa If you have any questions or need assistance, please don't hesitate to contact our support team at support@shelf.nu. +${extraMessage ? extraMessage : ""} + Thanks, The Shelf Team `; diff --git a/app/modules/invite/service.server.ts b/app/modules/invite/service.server.ts index bdc90cb78..f3ab68df9 100644 --- a/app/modules/invite/service.server.ts +++ b/app/modules/invite/service.server.ts @@ -112,6 +112,7 @@ export async function createInvite( teamMemberName: TeamMember["name"]; teamMemberId?: Invite["teamMemberId"]; userId: string; + extraMessage?: string | null; } ) { let { @@ -122,6 +123,7 @@ export async function createInvite( teamMemberName, teamMemberId, userId, + extraMessage, } = payload; try { @@ -261,8 +263,8 @@ export async function createInvite( sendEmail({ to: inviteeEmail, subject: `✉️ You have been invited to ${invite.organization.name}`, - text: inviteEmailText({ invite, token }), - html: invitationTemplateString({ invite, token }), + text: inviteEmailText({ invite, token, extraMessage }), + html: invitationTemplateString({ invite, token, extraMessage }), }); return invite; @@ -574,6 +576,7 @@ export async function bulkInviteUsers({ roles: [user.role], teamMemberName: user.email.split("@")[0], userId, + extraMessage: message, }); } } catch (cause) { From de6319aa8cca42ee28519a5ebe838a5ae72f1797 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 28 Jan 2025 12:06:52 +0100 Subject: [PATCH 07/11] feat(reminders): optimize bulk sending invite process --- app/modules/invite/service.server.ts | 139 +++++++++++++++--- app/routes/api+/settings.import-users.ts | 2 +- ...f.nu-example-import-users-from-content.csv | 2 +- 3 files changed, 120 insertions(+), 23 deletions(-) diff --git a/app/modules/invite/service.server.ts b/app/modules/invite/service.server.ts index f3ab68df9..a3b5a90ff 100644 --- a/app/modules/invite/service.server.ts +++ b/app/modules/invite/service.server.ts @@ -8,6 +8,7 @@ import type { import { InviteStatuses } from "@prisma/client"; import type { AppLoadContext, LoaderFunctionArgs } from "@remix-run/node"; import jwt from "jsonwebtoken"; +import { uniqBy } from "lodash"; import type { z } from "zod"; import type { InviteUserFormSchema } from "~/components/settings/invite-user-dialog"; import { db } from "~/database/db.server"; @@ -543,42 +544,138 @@ export async function getPaginatedAndFilterableSettingInvites({ } } +type InviteUserSchema = z.infer; + export async function bulkInviteUsers({ users, userId, organizationId, - message, + extraMessage, }: { - users: z.infer[]; + users: Omit[]; userId: User["id"]; organizationId: Organization["id"]; - message?: string | null; + extraMessage?: string | null; }) { try { - for (const user of users) { - const existingInvite = await db.invite.count({ - where: { - status: "PENDING", - inviteeEmail: user.email, - organizationId, + // Filter out duplicate emails + const uniquePayloads = uniqBy(users, (user) => user.email); + + // Batch validate all emails against SS + await Promise.all( + uniquePayloads.map((payload) => + validateInvite(payload.email, organizationId) + ) + ); + + // Batch check for existing users + const emails = uniquePayloads.map((p) => p.email); + const existingUsers = await db.user.findMany({ + where: { + email: { in: emails }, + userOrganizations: { + some: { organizationId }, }, - }); + }, + select: { email: true }, + }); - /** If user is already invited, then we do not invite him/her again. */ - if (existingInvite) { - continue; - } + const existingEmailsInOrg = new Set(existingUsers.map((u) => u.email)); - await createInvite({ + // Batch check for existing invites in one query + const existingInvites = await db.invite.findMany({ + where: { organizationId, - inviteeEmail: user.email, - inviterId: userId, - roles: [user.role], - teamMemberName: user.email.split("@")[0], - userId, - extraMessage: message, + inviteeEmail: { in: emails }, + status: InviteStatuses.PENDING, + expiresAt: { gt: new Date() }, + }, + select: { + inviteeEmail: true, + inviteeTeamMember: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + const existingInviteEmails = existingInvites.map((i) => i.inviteeEmail); + + /** + * We only have to send invite to the + * - users who have not PENDING invitation and + * - users who are not part of organization already + */ + const validPayloads = uniquePayloads.filter( + (p) => + !existingInviteEmails.includes(p.email) && + !existingEmailsInOrg.has(p.email) + ); + + const validPayloadsWithName = validPayloads.map((p) => ({ + ...p, + name: p.email.split("@")[0], + })); + + const createdTeamMembers = await db.teamMember.createManyAndReturn({ + data: validPayloadsWithName.map((p) => ({ + name: p.name, + organizationId, + })), + }); + + // Prepare invite data + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + INVITE_EXPIRY_TTL_DAYS); + + const invitesToCreate = validPayloadsWithName.map((payload) => ({ + inviterId: userId, + organizationId, + inviteeEmail: payload.email, + teamMemberId: + createdTeamMembers.find((tm) => tm.name === payload.name)?.id ?? "", + roles: [payload.role], + expiresAt, + inviteCode: generateRandomCode(6), + status: InviteStatuses.PENDING, + })); + + // Bulk create invites + const createdInvites = await db.invite.createManyAndReturn({ + data: invitesToCreate, + include: { + inviter: { select: { firstName: true, lastName: true } }, + organization: true, + }, + }); + + // Queue emails for sending - no need to await since it's handled by queue + createdInvites.forEach((invite) => { + const token = jwt.sign({ id: invite.id }, INVITE_TOKEN_SECRET, { + expiresIn: `${INVITE_EXPIRY_TTL_DAYS}d`, + }); + + sendEmail({ + to: invite.inviteeEmail, + subject: `✉️ You have been invited to ${invite.organization.name}`, + text: inviteEmailText({ invite, token, extraMessage }), + html: invitationTemplateString({ invite, token, extraMessage }), + }); + }); + + // Notify about skipped invites due to existing users + if (existingEmailsInOrg.size > 0) { + sendNotification({ + title: "Some invites were skipped", + message: `${existingEmailsInOrg.size} email(s) are already part of the organization`, + icon: { name: "success", variant: "primary" }, + senderId: userId, }); } + + return createdInvites; } catch (cause) { throw new ShelfError({ cause, diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts index 41217bd31..ee3180aae 100644 --- a/app/routes/api+/settings.import-users.ts +++ b/app/routes/api+/settings.import-users.ts @@ -51,7 +51,7 @@ export async function action({ context, request }: ActionFunctionArgs) { organizationId, userId, users, - message: formData.get("message") as string, + extraMessage: formData.get("message") as string, }); sendNotification({ diff --git a/public/static/shelf.nu-example-import-users-from-content.csv b/public/static/shelf.nu-example-import-users-from-content.csv index dbe779696..12a1eb82e 100644 --- a/public/static/shelf.nu-example-import-users-from-content.csv +++ b/public/static/shelf.nu-example-import-users-from-content.csv @@ -1,2 +1,2 @@ role,email -ADMIN,rohitsaini.codes+1@gmail.com \ No newline at end of file +ADMIN,example@shelf.nu \ No newline at end of file From 4902628f43cf91a641577769b26112905a7663d3 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Wed, 29 Jan 2025 11:17:08 +0100 Subject: [PATCH 08/11] feat(import-users): done changes for pr review --- .../settings/import-users-dialog.tsx | 18 ++---- app/components/shared/warning-box.tsx | 2 +- app/emails/invite-template.tsx | 52 +++++++++++++++--- app/modules/invite/service.server.ts | 44 +++++++++++---- app/routes/api+/settings.import-users.ts | 9 --- public/static/images/circle-alert.png | Bin 0 -> 910 bytes 6 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 public/static/images/circle-alert.png diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx index d211d0574..0ef93c848 100644 --- a/app/components/settings/import-users-dialog.tsx +++ b/app/components/settings/import-users-dialog.tsx @@ -127,18 +127,12 @@ export default function ImportUsersDialog({

Extra Considerations

-
-
    -
  • - The first row of the sheet will be ignored. Use it for column - headers as in the provided template. -
  • -
  • - If any of the rows contain invalid data, they will be skipped, - but the valid rows will still be processed. -
  • -
-
+
    +
  • + The first row of the sheet will be ignored. Use it for column + headers as in the provided template. +
  • +

Once you've uploaded your file, a summary of the processed diff --git a/app/components/shared/warning-box.tsx b/app/components/shared/warning-box.tsx index a6f92b342..6d8ae5a07 100644 --- a/app/components/shared/warning-box.tsx +++ b/app/components/shared/warning-box.tsx @@ -12,7 +12,7 @@ export function WarningBox({ return (

{invite.inviter.firstName} {invite.inviter.lastName} invites you to join Shelf as a member of {invite.organization.name} - ’s workspace. Click the link to accept the invite: + 's workspace. Click the link to accept the invite: + + {extraMessage ? ( +
+ alert-icon + + + Message from sender, please read carefully. + + + + {extraMessage} + +
+ ) : null} + + +
+ + ) : ( +
+

Invite Users via CSV Upload

+

+ Invite multiple users to your organization by uploading a CSV + file. To get started,{" "} + +

+ + <> + IMPORTANT: Use the provided template to + ensure proper formatting. Uploading incorrectly formatted + files may cause errors. + + +

Base Rules and Limitations

+
    +
  • + You must use , (comma) as a delimiter in your CSV file. +
  • +
  • + Only valid roles are ADMIN, BASE and{" "} + SELF_SERVICE. Role column is case-sensitive. +
  • +
  • + Each row represents a new user to be invited. Ensure the email + column is valid. +
  • +
  • + Invited users will receive an email with a link to join the + organization. +
  • +
+ +

Extra Considerations

+
    +
  • + The first row of the sheet will be ignored. Use it for column + headers as in the provided template. +
  • +
+ +

+ Once you've uploaded your file, a summary of the processed + invitations will be displayed, along with any errors + encountered. +

+ + - download our CSV template. - -

- - <> - IMPORTANT: Use the provided template to ensure - proper formatting. Uploading incorrectly formatted files may - cause errors. - - -

Base Rules and Limitations

-
    -
  • - You must use , (comma) as a delimiter in your CSV file. -
  • -
  • - Only valid roles are ADMIN, BASE and{" "} - SELF_SERVICE. Role column is case-sensitive. -
  • -
  • - Each row represents a new user to be invited. Ensure the email - column is valid. -
  • -
  • - Invited users will receive an email with a link to join the - organization. -
  • -
- -

Extra Considerations

-
    -
  • - The first row of the sheet will be ignored. Use it for column - headers as in the provided template. -
  • -
- -

- Once you've uploaded your file, a summary of the processed - invitations will be displayed, along with any errors encountered. -

- - - - - - - -

- {fetcher.data?.error?.message} -

-
- - -
-
+ + + + + +

+ {fetcher.data?.error?.message} +

+
+ + + + + )}
From d9e7229c421a00f80dfb4b80a8afe4067c01b65e Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Wed, 29 Jan 2025 21:25:56 +0100 Subject: [PATCH 10/11] feat(import-users): showing skipped users table, add extraMessage, update colors in email --- .../import-users-dialog.tsx | 61 ++++++++--------- .../import-users-success-content.tsx | 68 +++++++++++++++++++ .../import-users-table.tsx | 50 ++++++++++++++ app/emails/invite-template.tsx | 8 +-- app/modules/invite/service.server.ts | 47 ++++++++++++- app/routes/_layout+/settings.team.users.tsx | 2 +- app/routes/api+/settings.import-users.ts | 8 ++- 7 files changed, 199 insertions(+), 45 deletions(-) rename app/components/settings/{ => import-users-dialog}/import-users-dialog.tsx (79%) create mode 100644 app/components/settings/import-users-dialog/import-users-success-content.tsx create mode 100644 app/components/settings/import-users-dialog/import-users-table.tsx diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog/import-users-dialog.tsx similarity index 79% rename from app/components/settings/import-users-dialog.tsx rename to app/components/settings/import-users-dialog/import-users-dialog.tsx index fea31c924..6db6cf27e 100644 --- a/app/components/settings/import-users-dialog.tsx +++ b/app/components/settings/import-users-dialog/import-users-dialog.tsx @@ -1,22 +1,33 @@ import { cloneElement, useState } from "react"; import { useNavigate } from "@remix-run/react"; import { UploadIcon } from "lucide-react"; -import { ClientOnly } from "remix-utils/client-only"; +import type { z } from "zod"; import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; import { isFormProcessing } from "~/utils/form"; import { tw } from "~/utils/tw"; -import Input from "../forms/input"; -import { Dialog, DialogPortal } from "../layout/dialog"; -import { Button } from "../shared/button"; -import { WarningBox } from "../shared/warning-box"; -import When from "../when/when"; -import SuccessAnimation from "../zxing-scanner/success-animation"; +import Input from "../../forms/input"; +import { Dialog, DialogPortal } from "../../layout/dialog"; +import { Button } from "../../shared/button"; +import { WarningBox } from "../../shared/warning-box"; +import When from "../../when/when"; +import type { InviteUserFormSchema } from "../invite-user-dialog"; +import ImportUsersSuccessContent from "./import-users-success-content"; type ImportUsersDialogProps = { className?: string; trigger?: React.ReactElement<{ onClick: () => void }>; }; +type ImportUser = z.infer; + +export type FetcherData = { + error?: { message?: string }; + success?: boolean; + inviteSentUsers?: ImportUser[]; + skippedUsers?: ImportUser[]; + extraMessage?: string; +}; + export default function ImportUsersDialog({ className, trigger, @@ -27,10 +38,7 @@ export default function ImportUsersDialog({ const navigate = useNavigate(); - const fetcher = useFetcherWithReset<{ - error?: { message?: string }; - success?: boolean; - }>(); + const fetcher = useFetcherWithReset(); const disabled = isFormProcessing(fetcher.state); function openDialog() { @@ -76,9 +84,8 @@ export default function ImportUsersDialog({ } > - {fetcher.data?.success || true ? ( -
-
- - {() => } - -
- -

Successfully invited users

-

- Users from the csv file has been invited successfully. -

- -
- - -
-
+ {fetcher.data?.success ? ( + ) : (

Invite Users via CSV Upload

diff --git a/app/components/settings/import-users-dialog/import-users-success-content.tsx b/app/components/settings/import-users-dialog/import-users-success-content.tsx new file mode 100644 index 000000000..d67617b5b --- /dev/null +++ b/app/components/settings/import-users-dialog/import-users-success-content.tsx @@ -0,0 +1,68 @@ +import { ClientOnly } from "remix-utils/client-only"; +import { Button } from "~/components/shared/button"; +import { WarningBox } from "~/components/shared/warning-box"; +import When from "~/components/when/when"; +import SuccessAnimation from "~/components/zxing-scanner/success-animation"; +import { tw } from "~/utils/tw"; +import type { FetcherData } from "./import-users-dialog"; +import ImportUsersTable from "./import-users-table"; + +type ImportUsersSuccessContentProps = { + className?: string; + data: FetcherData; + onClose: () => void; + onViewInvites: () => void; +}; + +export default function ImportUsersSuccessContent({ + className, + data, + onClose, + onViewInvites, +}: ImportUsersSuccessContentProps) { + return ( +
+ {() => } + +

Successfully invited users

+

+ Users from the csv file has been invited successfully. +

+ + + + {data.extraMessage ?? ""} + + + + + + + + + + +
+ + +
+
+ ); +} diff --git a/app/components/settings/import-users-dialog/import-users-table.tsx b/app/components/settings/import-users-dialog/import-users-table.tsx new file mode 100644 index 000000000..85379fdc5 --- /dev/null +++ b/app/components/settings/import-users-dialog/import-users-table.tsx @@ -0,0 +1,50 @@ +import type { z } from "zod"; +import { tw } from "~/utils/tw"; +import type { InviteUserFormSchema } from "../invite-user-dialog"; + +type ImportUsersTableProps = { + className?: string; + style?: React.CSSProperties; + title: string; + users: z.infer[]; +}; + +export default function ImportUsersTable({ + className, + style, + title, + users, +}: ImportUsersTableProps) { + return ( +
+

{title}

+ + + + + + + + + + {users.map((user) => ( + + + + + ))} + +
+ Email + + Role +
{user.email}{user.role}
+
+ ); +} diff --git a/app/emails/invite-template.tsx b/app/emails/invite-template.tsx index dbc954f15..c957420e7 100644 --- a/app/emails/invite-template.tsx +++ b/app/emails/invite-template.tsx @@ -49,7 +49,7 @@ export function InvitationEmailTemplate({
@@ -67,7 +65,7 @@ export function InvitationEmailTemplate({ style={{ fontSize: "16px", fontWeight: "bold", - color: "#FDB022", + color: "#B54708", margin: "0px", }} > @@ -77,7 +75,7 @@ export function InvitationEmailTemplate({ diff --git a/app/modules/invite/service.server.ts b/app/modules/invite/service.server.ts index 6f101570c..c34e5494c 100644 --- a/app/modules/invite/service.server.ts +++ b/app/modules/invite/service.server.ts @@ -611,7 +611,12 @@ export async function bulkInviteUsers({ senderId: userId, }); - return; + return { + inviteSentUsers: [], + skippedUsers: users, + extraMessage: + "All users in csv file are already invited to the organization.", + }; } /* All emails are already in organization */ @@ -623,7 +628,30 @@ export async function bulkInviteUsers({ senderId: userId, }); - return; + return { + inviteSentUsers: [], + skippedUsers: users, + extraMessage: + "All user in csv file are already part of your organization.", + }; + } + + /* All emails are either in organization already or invited already */ + if (existingInvites.length + existingUsers.length === emails.length) { + sendNotification({ + title: "0 users invited", + message: + "All users in file are either in organization or already invited.", + icon: { name: "success", variant: "error" }, + senderId: userId, + }); + + return { + inviteSentUsers: [], + skippedUsers: users, + extraMessage: + "All users in file are either in organization or already invited.", + }; } const existingInviteEmails = existingInvites.map((i) => i.inviteeEmail); @@ -697,7 +725,20 @@ export async function bulkInviteUsers({ senderId: userId, }); - return createdInvites; + const skippedUsers = users.filter( + (user) => + existingInviteEmails.includes(user.email) || + existingEmailsInOrg.has(user.email) + ); + + return { + inviteSentUsers: validPayloads, + skippedUsers, + extraMessage: + createdInvites.length > 10 + ? "You are sending more than 10 invites, so some of the emails might get slightly delayed. If one of the invitees hasnt received the email within 5-10 minutes, you can use the Resend invite feature to send the email again." + : undefined, + }; } catch (cause) { throw new ShelfError({ cause, diff --git a/app/routes/_layout+/settings.team.users.tsx b/app/routes/_layout+/settings.team.users.tsx index d7009075d..1c786302a 100644 --- a/app/routes/_layout+/settings.team.users.tsx +++ b/app/routes/_layout+/settings.team.users.tsx @@ -11,7 +11,7 @@ import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { ListContentWrapper } from "~/components/list/content-wrapper"; import { Filters } from "~/components/list/filters"; -import ImportUsersDialog from "~/components/settings/import-users-dialog"; +import ImportUsersDialog from "~/components/settings/import-users-dialog/import-users-dialog"; import InviteUserDialog from "~/components/settings/invite-user-dialog"; import { Button } from "~/components/shared/button"; import { InfoTooltip } from "~/components/shared/info-tooltip"; diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts index 9bec79609..c943cc5cc 100644 --- a/app/routes/api+/settings.import-users.ts +++ b/app/routes/api+/settings.import-users.ts @@ -46,14 +46,18 @@ export async function action({ context, request }: ActionFunctionArgs) { InviteUserFormSchema.array() ); - await bulkInviteUsers({ + const response = await bulkInviteUsers({ organizationId, userId, users, extraMessage: formData.get("message") as string, }); - return json(data({ success: true })); + if (!response) { + return json(data({ success: true })); + } + + return json(data({ success: true, ...response })); } catch (cause) { const reason = makeShelfError(cause, { userId }); return json(error(reason), { status: reason.status }); From 9f7a20f91c76373dab13064eb8e5d0bd16f2c032 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Thu, 30 Jan 2025 12:48:16 +0200 Subject: [PATCH 11/11] small content adjustments --- .../import-users-dialog/import-users-success-content.tsx | 5 +++-- .../settings/import-users-dialog/import-users-table.tsx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/components/settings/import-users-dialog/import-users-success-content.tsx b/app/components/settings/import-users-dialog/import-users-success-content.tsx index d67617b5b..4b42a300e 100644 --- a/app/components/settings/import-users-dialog/import-users-success-content.tsx +++ b/app/components/settings/import-users-dialog/import-users-success-content.tsx @@ -29,9 +29,10 @@ export default function ImportUsersSuccessContent({ > {() => } -

Successfully invited users

+

Import completed

- Users from the csv file has been invited successfully. + Users from the csv file has been invited. Below you can find a summary + of the invited users.

diff --git a/app/components/settings/import-users-dialog/import-users-table.tsx b/app/components/settings/import-users-dialog/import-users-table.tsx index 85379fdc5..f9981de13 100644 --- a/app/components/settings/import-users-dialog/import-users-table.tsx +++ b/app/components/settings/import-users-dialog/import-users-table.tsx @@ -1,4 +1,5 @@ import type { z } from "zod"; +import { organizationRolesMap } from "~/routes/_layout+/settings.team"; import { tw } from "~/utils/tw"; import type { InviteUserFormSchema } from "../invite-user-dialog"; @@ -40,7 +41,7 @@ export default function ImportUsersTable({ {users.map((user) => ( {user.email} - {user.role} + {organizationRolesMap[user.role]} ))}