From bb9038d346603c21f0fe35dda46ab095a2cbed17 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 17 Dec 2024 15:40:30 +0100 Subject: [PATCH 01/32] feat(asset-reminder): create UI for asset reminders --- app/components/assets/actions-dropdown.tsx | 38 +++++- app/components/assets/set-reminder-dialog.tsx | 33 ++++++ .../set-reminder-form/set-reminder-form.tsx | 109 ++++++++++++++++++ .../team-members-selector.tsx | 104 +++++++++++++++++ app/components/icons/library.tsx | 19 +++ app/components/layout/contextual-modal.tsx | 2 +- app/components/layout/dialog.tsx | 9 +- app/components/shared/icons-map.tsx | 5 +- app/components/shared/separator.tsx | 2 +- app/modules/team-member/service.server.ts | 1 + app/routes/_layout+/assets.$assetId.tsx | 10 ++ 11 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 app/components/assets/set-reminder-dialog.tsx create mode 100644 app/components/assets/set-reminder-form/set-reminder-form.tsx create mode 100644 app/components/assets/set-reminder-form/team-members-selector.tsx diff --git a/app/components/assets/actions-dropdown.tsx b/app/components/assets/actions-dropdown.tsx index 5e2611b0d..213757237 100644 --- a/app/components/assets/actions-dropdown.tsx +++ b/app/components/assets/actions-dropdown.tsx @@ -20,6 +20,7 @@ import { userHasPermission } from "~/utils/permissions/permission.validator.clie import { tw } from "~/utils/tw"; import { DeleteAsset } from "./delete-asset"; import RelinkQrCodeDialog from "./relink-qr-code-dialog"; +import SetReminderDialog from "./set-reminder-dialog"; import { UpdateGpsCoordinatesForm } from "./update-gps-coordinates-form"; import Icon from "../icons/icon"; import { Button } from "../shared/button"; @@ -28,11 +29,12 @@ import When from "../when/when"; const ConditionalActionsDropdown = () => { const { asset } = useLoaderData(); const [isRelinkQrDialogOpen, setIsRelinkQrDialogOpen] = useState(false); + const [isSetReminderDialogOpen, setIsSetReminderDialogOpen] = useState(false); const assetCanBeReleased = asset.custody; const assetIsCheckedOut = asset.status === "CHECKED_OUT"; - const { roles, isSelfService } = useUserRoleHelper(); + const { roles, isSelfService, isAdministratorOrOwner } = useUserRoleHelper(); const user = useUserData(); const { @@ -171,16 +173,14 @@ const ConditionalActionsDropdown = () => { })} > + + + + + + + + + ); +} diff --git a/app/components/assets/set-reminder-form/team-members-selector.tsx b/app/components/assets/set-reminder-form/team-members-selector.tsx new file mode 100644 index 000000000..de80ef49e --- /dev/null +++ b/app/components/assets/set-reminder-form/team-members-selector.tsx @@ -0,0 +1,104 @@ +import type { Prisma } from "@prisma/client"; +import { CheckIcon, UserIcon } from "lucide-react"; +import { Separator } from "~/components/shared/separator"; +import When from "~/components/when/when"; +import { useModelFilters } from "~/hooks/use-model-filters"; +import { tw } from "~/utils/tw"; + +type TeamMembersSelectorProps = { + className?: string; + style?: React.CSSProperties; + error?: string; +}; + +export default function TeamMembersSelector({ + className, + style, + error, +}: TeamMembersSelectorProps) { + const { + items, + handleSearchQueryChange, + searchQuery, + handleSelectItemChange, + selectedItems, + } = useModelFilters({ + selectionMode: "none", + defaultValues: [], + model: { + name: "teamMember", + queryKey: "name", + deletedAt: null, + }, + countKey: "totalTeamMembers", + initialDataKey: "teamMembers", + }); + + return ( +
+
+ + +
+ +

{error}

+
+ + + + {selectedItems.map((item, i) => ( + + ))} + + {items.map((item) => { + const teamMember = item as unknown as Prisma.TeamMemberGetPayload<{ + include: { user: { select: { profilePicture: true } } }; + }>; + const isTeamMemberSelected = selectedItems.includes(teamMember.id); + + return ( +
{ + handleSelectItemChange(teamMember.id); + }} + > +
+ {`${teamMember.name}'s +

{teamMember.name}

+
+ + + + +
+ ); + })} +
+ ); +} diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index 4e9d80fba..f48fbf237 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -1821,3 +1821,22 @@ export const AlertIcon = (props: SVGProps) => ( /> ); + +export const AlarmClockIcon = (props: SVGProps) => ( + + + +); diff --git a/app/components/layout/contextual-modal.tsx b/app/components/layout/contextual-modal.tsx index 27f17bdeb..5253d47d7 100644 --- a/app/components/layout/contextual-modal.tsx +++ b/app/components/layout/contextual-modal.tsx @@ -32,7 +32,7 @@ const Dialog = ({
diff --git a/app/components/layout/dialog.tsx b/app/components/layout/dialog.tsx index de75c4651..fb6de43d8 100644 --- a/app/components/layout/dialog.tsx +++ b/app/components/layout/dialog.tsx @@ -10,12 +10,14 @@ export const Dialog = ({ open, onClose, className, + headerClassName, }: { title: string | ReactNode; children: ReactNode; open: boolean; onClose: Function; className?: string; + headerClassName?: string; }) => open ? (
-
+
{title} + + + + tm.id), + }} + open={isEditDialogOpen} + onClose={() => { + setIsEditDialogOpen(false); + }} + /> + + ); +} diff --git a/app/components/assets/reminders/set-or-edit-reminder-dialog.tsx b/app/components/assets/reminders/set-or-edit-reminder-dialog.tsx new file mode 100644 index 000000000..2a8bd58f1 --- /dev/null +++ b/app/components/assets/reminders/set-or-edit-reminder-dialog.tsx @@ -0,0 +1,158 @@ +import { useEffect } from "react"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { Link } from "react-router-dom"; +import { useZorm } from "react-zorm"; +import { z } from "zod"; +import Input from "~/components/forms/input"; +import { Button } from "~/components/shared/button"; +import { Separator } from "~/components/shared/separator"; +import { dateForDateTimeInputValue } from "~/utils/date-fns"; +import { isFormProcessing } from "~/utils/form"; +import TeamMembersSelector from "./team-members-selector"; +import { Dialog, DialogPortal } from "../../layout/dialog"; + +export const setReminderSchema = z.object({ + name: z.string().min(1, "Please enter name."), + message: z.string().min(1, "Please enter message."), + alertDateTime: z.coerce.date().min(new Date()), + teamMembers: z + .array(z.string()) + .min(1, "Please select at least one team member"), +}); + +type SetOrEditReminderDialogProps = { + open: boolean; + onClose: () => void; + reminder?: z.infer; +}; + +export default function SetOrEditReminderDialog({ + open, + onClose, + reminder, +}: SetOrEditReminderDialogProps) { + const navigation = useNavigation(); + const disabled = isFormProcessing(navigation.state); + const actionData = useActionData<{ success: boolean }>(); + + const zo = useZorm("SetOrEditReminder", setReminderSchema); + + useEffect( + function handleOnSuccess() { + if (actionData?.success) { + onClose && onClose(); + } + }, + [actionData, onClose] + ); + + return ( + + +

Set Reminder

+

+ Notify you and / or others via email about this asset. +

+
+ } + > + +
+ + + + +
+ +

+ This will show in the reminder mail that gets sent to selected + team member(s). Curious about the reminder mail?{" "} + + See a sample + + . +

+
+ +
+ +

+ We will send the reminder at this date/time. +

+
+
+
+ +

Select team member(s)

+ +
+
+ + +
+ +
+ + ); +} diff --git a/app/components/assets/set-reminder-form/team-members-selector.tsx b/app/components/assets/reminders/team-members-selector.tsx similarity index 97% rename from app/components/assets/set-reminder-form/team-members-selector.tsx rename to app/components/assets/reminders/team-members-selector.tsx index de80ef49e..55746317f 100644 --- a/app/components/assets/set-reminder-form/team-members-selector.tsx +++ b/app/components/assets/reminders/team-members-selector.tsx @@ -9,12 +9,14 @@ type TeamMembersSelectorProps = { className?: string; style?: React.CSSProperties; error?: string; + defaultValues?: string[]; }; export default function TeamMembersSelector({ className, style, error, + defaultValues, }: TeamMembersSelectorProps) { const { items, @@ -24,7 +26,7 @@ export default function TeamMembersSelector({ selectedItems, } = useModelFilters({ selectionMode: "none", - defaultValues: [], + defaultValues, model: { name: "teamMember", queryKey: "name", diff --git a/app/components/assets/set-reminder-dialog.tsx b/app/components/assets/set-reminder-dialog.tsx deleted file mode 100644 index 91734f892..000000000 --- a/app/components/assets/set-reminder-dialog.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Dialog, DialogPortal } from "../layout/dialog"; -import SetReminderForm from "./set-reminder-form/set-reminder-form"; - -type SetReminderDialogProps = { - open: boolean; - onClose: () => void; -}; - -export default function SetReminderDialog({ - open, - onClose, -}: SetReminderDialogProps) { - return ( - - -

Set Reminder

-

- Notify you and / or others via email about this asset. -

-
- } - > - -
- - ); -} diff --git a/app/components/assets/set-reminder-form/set-reminder-form.tsx b/app/components/assets/set-reminder-form/set-reminder-form.tsx deleted file mode 100644 index d8e0bed90..000000000 --- a/app/components/assets/set-reminder-form/set-reminder-form.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect } from "react"; -import { Form, Link, useActionData, useNavigation } from "@remix-run/react"; -import { useZorm } from "react-zorm"; -import { z } from "zod"; -import Input from "~/components/forms/input"; -import { Button } from "~/components/shared/button"; -import { Separator } from "~/components/shared/separator"; -import { isFormProcessing } from "~/utils/form"; -import TeamMembersSelector from "./team-members-selector"; - -export const setReminderSchema = z.object({ - name: z.string().min(1, "Please enter name."), - message: z.string().min(1, "Please enter message."), - alertDateTime: z.coerce.date().min(new Date()), - teamMembers: z - .array(z.string()) - .min(1, "Please select at least one team member"), -}); - -export type SetReminderFormProps = { - onCancel?: () => void; -}; - -export default function SetReminderForm({ onCancel }: SetReminderFormProps) { - const navigation = useNavigation(); - const disabled = isFormProcessing(navigation.state); - const actionData = useActionData<{ success: boolean }>(); - - const zo = useZorm("SetReminder", setReminderSchema); - - useEffect( - function handleOnSuccess() { - if (actionData?.success) { - onCancel && onCancel(); - } - }, - [actionData, onCancel] - ); - - return ( -
-
- - - - -
- -

- This will show in the reminder mail that gets sent to selected team - member(s). Curious about the reminder mail?{" "} - - See a sample - - . -

-
- -
- -

- We will send the reminder at this date/time. -

-
-
-
- -

Select team member(s)

- -
-
- - -
-
- ); -} diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 99548095c..c1e3b5329 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { z } from "zod"; +import ActionsDropdown from "~/components/assets/reminders/actions-dropdown"; import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { @@ -12,6 +13,7 @@ import { import { Td, Th } from "~/components/table"; import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; import { getPaginatedAndFilterableReminders } from "~/modules/asset/service.server"; +import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; import { getDateTimeFormat } from "~/utils/client-hints"; import { makeShelfError } from "~/utils/error"; import { data, error, getParams } from "~/utils/http.server"; @@ -59,6 +61,13 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }).format(reminder.alertDateTime), })); + /** We need teamMembers in SetReminderForm */ + const { teamMembers, totalTeamMembers } = + await getPaginatedAndFilterableTeamMembers({ + request, + organizationId, + }); + return json( data({ header, @@ -68,6 +77,8 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { page, perPage, totalPages, + teamMembers, + totalTeamMembers, }) ); } catch (cause) { @@ -104,14 +115,14 @@ function ListContent({ {item.name} {item.message} {item.displayDate} - + {item.teamMembers.map((teamMember) => ( {teamMember.name} ))} + + + ); } diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 042a2bf00..061ba4d35 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -12,7 +12,7 @@ import ActionsDropdown from "~/components/assets/actions-dropdown"; import { AssetImage } from "~/components/assets/asset-image"; import { AssetStatusBadge } from "~/components/assets/asset-status-badge"; import BookingActionsDropdown from "~/components/assets/booking-actions-dropdown"; -import { setReminderSchema } from "~/components/assets/set-reminder-form/set-reminder-form"; +import { setReminderSchema } from "~/components/assets/reminders/set-or-edit-reminder-dialog"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; From d467b063e6a1c1b8a35340c74a5baf43f22e8d5b Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Wed, 18 Dec 2024 16:04:20 +0100 Subject: [PATCH 07/32] feat(asset-reminder): create backend to edit reminder --- .../assets/reminders/actions-dropdown.tsx | 3 +- .../reminders/set-or-edit-reminder-dialog.tsx | 15 ++++- app/modules/asset/service.server.ts | 42 +++++++++++++ .../_layout+/assets.$assetId.alerts.tsx | 61 ++++++++++++++++++- 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/app/components/assets/reminders/actions-dropdown.tsx b/app/components/assets/reminders/actions-dropdown.tsx index b04947c9e..8773db210 100644 --- a/app/components/assets/reminders/actions-dropdown.tsx +++ b/app/components/assets/reminders/actions-dropdown.tsx @@ -28,7 +28,7 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) { onOpenChange={setIsDropdownOpen} modal={false} > - + @@ -54,6 +54,7 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) { void; - reminder?: z.infer; + reminder?: z.infer & { id: string }; }; export default function SetOrEditReminderDialog({ @@ -37,6 +37,8 @@ export default function SetOrEditReminderDialog({ const zo = useZorm("SetOrEditReminder", setReminderSchema); + const isEdit = !!reminder; + useEffect( function handleOnSuccess() { if (actionData?.success) { @@ -69,7 +71,16 @@ export default function SetOrEditReminderDialog({ className="grid grid-cols-1 divide-x md:grid-cols-2" >
- + + {isEdit ? ( + + ) : ( + false + )} & { teamMembers: TeamMember["id"][] }) { + try { + /** This will act as a validation to check if reminder exists */ + const reminder = await db.assetReminder.findFirstOrThrow({ + where: { id, organizationId }, + }); + + const updatedReminder = await db.assetReminder.update({ + where: { id: reminder.id }, + data: { + name, + message, + alertDateTime, + teamMembers: { + set: [], // set empty so that if any team member is removed, the relation is removed + connect: teamMembers.map((id) => ({ id })), // then connect + }, + }, + }); + + return updatedReminder; + } catch (cause) { + throw new ShelfError({ + cause, + message: isNotFoundError(cause) + ? "Reminder not found or you are viewing in wrong organization." + : "Something went wrong while editing reminder.", + label, + }); + } +} diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index c1e3b5329..891e4d2ca 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -1,7 +1,9 @@ import type { Prisma } from "@prisma/client"; -import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { z } from "zod"; import ActionsDropdown from "~/components/assets/reminders/actions-dropdown"; +import { setReminderSchema } from "~/components/assets/reminders/set-or-edit-reminder-dialog"; import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { @@ -12,11 +14,15 @@ import { } from "~/components/shared/tooltip"; import { Td, Th } from "~/components/table"; import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; -import { getPaginatedAndFilterableReminders } from "~/modules/asset/service.server"; +import { + editAssetReminder, + getPaginatedAndFilterableReminders, +} from "~/modules/asset/service.server"; import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; +import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; import { getDateTimeFormat } from "~/utils/client-hints"; import { makeShelfError } from "~/utils/error"; -import { data, error, getParams } from "~/utils/http.server"; +import { data, error, getParams, parseData } from "~/utils/http.server"; import { PermissionAction, PermissionEntity, @@ -87,6 +93,55 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { } } +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + try { + const formData = await request.formData(); + + const { intent } = parseData( + formData, + z.object({ intent: z.enum(["edit-reminder"]) }) + ); + + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.asset, + action: PermissionAction.update, + }); + + switch (intent) { + case "edit-reminder": { + const payload = parseData( + formData, + setReminderSchema.extend({ id: z.string() }) + ); + + await editAssetReminder({ + id: payload.id, + name: payload.name, + message: payload.message, + alertDateTime: payload.alertDateTime, + teamMembers: payload.teamMembers, + organizationId, + }); + + return json(data({ success: true })); + } + + default: { + checkExhaustiveSwitch(intent); + return json(data(null)); + } + } + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} + export default function AssetAlerts() { return ( Date: Wed, 18 Dec 2024 16:32:48 +0100 Subject: [PATCH 08/32] feat(asset-reminder): create feature to delete a reminder --- .../assets/reminders/actions-dropdown.tsx | 8 ++- .../assets/reminders/delete-reminder.tsx | 69 +++++++++++++++++++ app/modules/asset/service.server.ts | 21 ++++++ .../_layout+/assets.$assetId.alerts.tsx | 26 ++++++- 4 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 app/components/assets/reminders/delete-reminder.tsx diff --git a/app/components/assets/reminders/actions-dropdown.tsx b/app/components/assets/reminders/actions-dropdown.tsx index 8773db210..5d64bc562 100644 --- a/app/components/assets/reminders/actions-dropdown.tsx +++ b/app/components/assets/reminders/actions-dropdown.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from "~/components/shared/dropdown"; import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; +import DeleteReminder from "./delete-reminder"; import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog"; type ActionsDropdownProps = { @@ -33,11 +34,11 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) { - + + + + ; +}; + +export default function DeleteReminder({ reminder }: DeleteReminderProps) { + const navigation = useNavigation(); + const disabled = isFormProcessing(navigation.state); + + return ( + + + Delete + + + + +
+ + + +
+ Delete {reminder.name} + + Are you sure you want to delete this reminder? This action cannot be + undone. + +
+ +
+ + + + +
+ + + + +
+
+
+
+
+ ); +} diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 38b7b127d..e38fed18e 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -3234,3 +3234,24 @@ export async function editAssetReminder({ }); } } + +export async function deleteAssetReminder({ + id, + organizationId, +}: Pick) { + try { + const deletedReminder = await db.assetReminder.delete({ + where: { id, organizationId }, + }); + + return deletedReminder; + } catch (cause) { + throw new ShelfError({ + cause, + message: isNotFoundError(cause) + ? "Reminder not found or you are viewing in wrong organization." + : "Something went wrong while deleting reminder.", + label, + }); + } +} diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 891e4d2ca..c4f52f2ca 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -15,12 +15,14 @@ import { import { Td, Th } from "~/components/table"; import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; import { + deleteAssetReminder, editAssetReminder, getPaginatedAndFilterableReminders, } from "~/modules/asset/service.server"; import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; import { getDateTimeFormat } from "~/utils/client-hints"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError } from "~/utils/error"; import { data, error, getParams, parseData } from "~/utils/http.server"; import { @@ -102,7 +104,7 @@ export async function action({ context, request }: ActionFunctionArgs) { const { intent } = parseData( formData, - z.object({ intent: z.enum(["edit-reminder"]) }) + z.object({ intent: z.enum(["edit-reminder", "delete-reminder"]) }) ); const { organizationId } = await requirePermission({ @@ -128,6 +130,28 @@ export async function action({ context, request }: ActionFunctionArgs) { organizationId, }); + sendNotification({ + title: "Reminder updated", + message: "Your asset reminder has been updated successfully", + icon: { name: "trash", variant: "error" }, + senderId: authSession.userId, + }); + + return json(data({ success: true })); + } + + case "delete-reminder": { + const { id } = parseData(formData, z.object({ id: z.string().min(1) })); + + await deleteAssetReminder({ id, organizationId }); + + sendNotification({ + title: "Reminder deleted", + message: "Your asset reminder has been deleted successfully", + icon: { name: "trash", variant: "error" }, + senderId: authSession.userId, + }); + return json(data({ success: true })); } From dbb2ec239306bf48ef6ded58fc838ef103d9cdc2 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Thu, 19 Dec 2024 18:00:19 +0100 Subject: [PATCH 09/32] feat(asset-reminders): add validation to select teamMember with user only --- .../reminders/team-members-selector.tsx | 1 + app/modules/asset/service.server.ts | 40 +++++++++++++++++-- app/modules/team-member/service.server.ts | 14 ++++++- .../_layout+/assets.$assetId.alerts.tsx | 1 + app/routes/_layout+/assets.$assetId.tsx | 3 ++ app/routes/api+/model-filters.ts | 14 +++++-- 6 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/components/assets/reminders/team-members-selector.tsx b/app/components/assets/reminders/team-members-selector.tsx index 55746317f..269b6854a 100644 --- a/app/components/assets/reminders/team-members-selector.tsx +++ b/app/components/assets/reminders/team-members-selector.tsx @@ -31,6 +31,7 @@ export default function TeamMembersSelector({ name: "teamMember", queryKey: "name", deletedAt: null, + userIsNotNull: true, }, countKey: "totalTeamMembers", initialDataKey: "teamMembers", diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 2410d38db..e3bf5e904 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -2759,6 +2759,8 @@ export async function createAssetReminder({ | "organizationId" > & { teamMembers: TeamMember["id"][] }) { try { + await validateTeamMembersForReminder(teamMembers); + const assetReminder = await db.assetReminder.create({ data: { name, @@ -2777,13 +2779,33 @@ export async function createAssetReminder({ } catch (cause) { throw new ShelfError({ cause, - message: "Something went wrong while creating asset reminder.", + message: isLikeShelfError(cause) + ? cause.message + : "Something went wrong while creating asset reminder.", label, additionalData: { assetId, organizationId, createdById }, }); } } +async function validateTeamMembersForReminder(teamMembers: TeamMember["id"][]) { + const teamMembersWithUserCount = await db.teamMember.count({ + where: { + id: { in: teamMembers }, + user: { isNot: null }, + }, + }); + + if (teamMembersWithUserCount !== teamMembers.length) { + throw new ShelfError({ + cause: null, + label, + message: + "Something went wrong while validating team members for reminder. Please contact support", + }); + } +} + export async function getPaginatedAndFilterableReminders({ assetId, organizationId, @@ -2840,6 +2862,8 @@ export async function editAssetReminder({ "id" | "name" | "message" | "alertDateTime" | "organizationId" > & { teamMembers: TeamMember["id"][] }) { try { + await validateTeamMembersForReminder(teamMembers); + /** This will act as a validation to check if reminder exists */ const reminder = await db.assetReminder.findFirstOrThrow({ where: { id, organizationId }, @@ -2860,11 +2884,19 @@ export async function editAssetReminder({ return updatedReminder; } catch (cause) { + let message = "Something went wrong while editing reminder."; + + if (isNotFoundError(cause)) { + message = "Reminder not found or you are viewing in wrong organization."; + } + + if (isLikeShelfError(cause)) { + message = cause.message; + } + throw new ShelfError({ cause, - message: isNotFoundError(cause) - ? "Reminder not found or you are viewing in wrong organization." - : "Something went wrong while editing reminder.", + message, label, }); } diff --git a/app/modules/team-member/service.server.ts b/app/modules/team-member/service.server.ts index 2ec4b9777..1af09e11a 100644 --- a/app/modules/team-member/service.server.ts +++ b/app/modules/team-member/service.server.ts @@ -131,6 +131,7 @@ export async function getTeamMembers(params: { /** Assets to be loaded per page */ perPage?: number; search?: string | null; + where?: Prisma.TeamMemberWhereInput; }) { const { organizationId, page = 1, perPage = 8, search } = params; @@ -142,6 +143,7 @@ export async function getTeamMembers(params: { let where: Prisma.TeamMemberWhereInput = { deletedAt: null, organizationId, + ...params.where, }; /** If the search string exists, add it to the where object */ @@ -161,7 +163,14 @@ export async function getTeamMembers(params: { orderBy: { createdAt: "desc" }, include: { custodies: true, - user: { select: { profilePicture: true } }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + profilePicture: true, + }, + }, }, }), @@ -183,9 +192,11 @@ export async function getTeamMembers(params: { export const getPaginatedAndFilterableTeamMembers = async ({ request, organizationId, + where, }: { request: LoaderFunctionArgs["request"]; organizationId: Organization["id"]; + where?: Prisma.TeamMemberWhereInput; }) => { const searchParams = getCurrentSearchParams(request); const { page, perPageParam, search } = getParamsValues(searchParams); @@ -199,6 +210,7 @@ export const getPaginatedAndFilterableTeamMembers = async ({ page, perPage, search, + where, }); const totalPages = Math.ceil(totalTeamMembers / perPage); diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index c4f52f2ca..7c2e4445c 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -74,6 +74,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { await getPaginatedAndFilterableTeamMembers({ request, organizationId, + where: { user: { isNot: null } }, }); return json( diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 061ba4d35..f933f9c56 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -82,6 +82,9 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { await getPaginatedAndFilterableTeamMembers({ request, organizationId, + where: { + user: { isNot: null }, + }, }); const header: HeaderData = { diff --git a/app/routes/api+/model-filters.ts b/app/routes/api+/model-filters.ts index cb2d7f8cc..d2f262d9b 100644 --- a/app/routes/api+/model-filters.ts +++ b/app/routes/api+/model-filters.ts @@ -40,6 +40,7 @@ export const ModelFiltersSchema = z.discriminatedUnion("name", [ BasicModelFilters.extend({ name: z.literal("teamMember"), deletedAt: z.string().nullable().optional(), + userIsNotNull: z.coerce.boolean().optional(), // To get only the teamMember which have a user associated }), BasicModelFilters.extend({ name: z.literal("booking"), @@ -72,8 +73,8 @@ export async function loader({ context, request }: LoaderFunctionArgs) { } /** Validating parameters */ - const { name, queryKey, queryValue, selectedValues, ...filters } = - parseData(searchParams, ModelFiltersSchema); + const modelFilters = parseData(searchParams, ModelFiltersSchema); + const { name, queryKey, queryValue, selectedValues } = modelFilters; const where: Record = { organizationId, @@ -84,13 +85,18 @@ export async function loader({ context, request }: LoaderFunctionArgs) { * - teamMember's name * - teamMember's user firstName, lastName and email */ - if (name === "teamMember") { + if (modelFilters.name === "teamMember") { where.OR.push( { name: { contains: queryValue, mode: "insensitive" } }, { user: { firstName: { contains: queryValue, mode: "insensitive" } } }, { user: { firstName: { contains: queryValue, mode: "insensitive" } } }, { user: { email: { contains: queryValue, mode: "insensitive" } } } ); + + where.deletedAt = modelFilters.deletedAt; + if (modelFilters.userIsNotNull) { + where.user = { isNot: null }; + } } else { where.OR.push({ [queryKey]: { contains: queryValue, mode: "insensitive" }, @@ -98,7 +104,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { } const queryData = (await db[name].dynamicFindMany({ - where: { ...where, ...filters }, + where, include: /** We need user's information to resolve teamMember's name */ name === "teamMember" From 84db2751bae356ce7d23bf5ea13333d0b8183d10 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 20 Dec 2024 15:51:37 +0100 Subject: [PATCH 10/32] feat(asset-reminders): create email template --- app/modules/asset/emails.tsx | 175 ++++++++++++++++++ .../_layout+/assets.$assetId.alerts.tsx | 2 +- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 app/modules/asset/emails.tsx diff --git a/app/modules/asset/emails.tsx b/app/modules/asset/emails.tsx new file mode 100644 index 000000000..fc0505a9b --- /dev/null +++ b/app/modules/asset/emails.tsx @@ -0,0 +1,175 @@ +import type { Asset, AssetReminder, User } from "@prisma/client"; +import { + Button, + Column, + Container, + Head, + Html, + render, + Row, + Text, +} from "@react-email/components"; +import colors from "tailwindcss/colors"; +import { LogoForEmail } from "~/emails/logo"; +import { styles } from "~/emails/styles"; +import { SERVER_URL } from "~/utils/env"; + +type AssetAlertEmailProps = { + user: User; + asset: Asset; + reminder: AssetReminder; + workspaceName: string; +}; + +export function assetAlertEmailText({ + user, + asset, + reminder, + workspaceName, +}: AssetAlertEmailProps) { + const userName = `${user.firstName?.trim()} ${user.lastName?.trim()}`; + + return `Asset reminder notice + +Hi ${userName}, your asset reminder date has been reached. Please +perform the required action for alert. + +${asset.title} +${asset.id} + +Reminder - ${reminder.name} + +${reminder.message} + +${SERVER_URL}/assets/${asset.id} + +This email was sent to ${user.email} because it is part of the Shelf workspace ${workspaceName}. +If you think you weren't supposed to have received this email please contact the owner of the workspace. + +Thanks, +The Shelf Team +`; +} + +function isAssetImageExpired(expiry: Asset["mainImageExpiration"]) { + if (!expiry) { + return false; + } + + const now = new Date(); + const expiration = new Date(expiry); + + return now > expiration; +} + +function AssetAlertEmailTemplate({ + asset, + reminder, + user, + workspaceName, +}: AssetAlertEmailProps) { + const userName = `${user.firstName?.trim()} ${user.lastName?.trim()}`; + + const isEmailExpired = isAssetImageExpired(asset.mainImageExpiration); + + return ( + + + Asset Reminder Notice + + + + + +
+ Asset Reminder Notice + + + Hi {userName}, your asset reminder date has been reached. Please + perform the required actions for this alert. + + + + {asset?.mainImage && !isEmailExpired ? ( + + asset + + ) : null} + + + + {asset.title} + + + {asset.id} + + + + + + {reminder.name} + + + {reminder.message} + + + + + + This email was sent to{" "} + {user.email} because it + is part of the Shelf workspace{" "} + {workspaceName}. If you + think you weren't supposed to have received this email please + contact the owner of the workspace. + + + +
+
+ + ); +} + +export function assetAlertEmailHtmlString(props: AssetAlertEmailProps) { + return render(); +} diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 7c2e4445c..035622a79 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -134,7 +134,7 @@ export async function action({ context, request }: ActionFunctionArgs) { sendNotification({ title: "Reminder updated", message: "Your asset reminder has been updated successfully", - icon: { name: "trash", variant: "error" }, + icon: { name: "success", variant: "success" }, senderId: authSession.userId, }); From 208cf6c4d13502175af2f741448dee633c5cce5f Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 20 Dec 2024 19:04:33 +0100 Subject: [PATCH 11/32] feat(sentry-erros): create scheduler for asset reminders --- .../migration.sql | 8 ++ app/database/schema.prisma | 19 ++-- app/entry.server.tsx | 11 ++ app/modules/asset/emails.tsx | 4 +- app/modules/asset/scheduler.server.ts | 48 ++++++++ app/modules/asset/service.server.ts | 12 ++ app/modules/asset/worker.server.ts | 104 ++++++++++++++++++ app/utils/error.ts | 3 +- 8 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 app/database/migrations/20241220170016_add_active_scheduler_reference_in_asset_reminder/migration.sql create mode 100644 app/modules/asset/scheduler.server.ts create mode 100644 app/modules/asset/worker.server.ts diff --git a/app/database/migrations/20241220170016_add_active_scheduler_reference_in_asset_reminder/migration.sql b/app/database/migrations/20241220170016_add_active_scheduler_reference_in_asset_reminder/migration.sql new file mode 100644 index 000000000..d3dcd2644 --- /dev/null +++ b/app/database/migrations/20241220170016_add_active_scheduler_reference_in_asset_reminder/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "Asset_title_description_idx"; + +-- DropIndex +DROP INDEX "TeamMember_name_idx"; + +-- AlterTable +ALTER TABLE "AssetReminder" ADD COLUMN "activeSchedulerReference" TEXT; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 1f9439d9e..35b89a702 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -108,19 +108,18 @@ model Asset { kit Kit? @relation(fields: [kitId], references: [id]) kitId String? - custody Custody? - notes Note[] - qrCodes Qr[] - reports ReportFound[] - tags Tag[] - customFields AssetCustomFieldValue[] - bookings Booking[] - reminders AssetReminder[] + custody Custody? + notes Note[] + qrCodes Qr[] + reports ReportFound[] + tags Tag[] + customFields AssetCustomFieldValue[] + bookings Booking[] + reminders AssetReminder[] //@@unique([title, organizationId]) //prisma doesnt support case insensitive unique index yet } - enum AssetStatus { AVAILABLE IN_CUSTODY @@ -754,6 +753,8 @@ model AssetReminder { message String alertDateTime DateTime + activeSchedulerReference String? + organization Organization @relation(fields: [organizationId], references: [id]) organizationId String diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 7c0dd4e86..b565a04e3 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -7,6 +7,7 @@ import { RemixServer } from "@remix-run/react"; import * as Sentry from "@sentry/remix"; import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; +import { regierAssetWorkers } from "./modules/asset/worker.server"; import { registerBookingWorkers } from "./modules/booking/worker.server"; import { ShelfError } from "./utils/error"; import { Logger } from "./utils/logger"; @@ -26,6 +27,16 @@ schedulerService }) ); }); + + await regierAssetWorkers().catch((cause) => { + Logger.error( + new ShelfError({ + cause, + message: "Something went wrong while registering asset workers.", + label: "Scheduler", + }) + ); + }); }) .finally(() => { // eslint-disable-next-line no-console diff --git a/app/modules/asset/emails.tsx b/app/modules/asset/emails.tsx index fc0505a9b..50861bfe5 100644 --- a/app/modules/asset/emails.tsx +++ b/app/modules/asset/emails.tsx @@ -15,8 +15,8 @@ import { styles } from "~/emails/styles"; import { SERVER_URL } from "~/utils/env"; type AssetAlertEmailProps = { - user: User; - asset: Asset; + user: Pick; + asset: Pick; reminder: AssetReminder; workspaceName: string; }; diff --git a/app/modules/asset/scheduler.server.ts b/app/modules/asset/scheduler.server.ts new file mode 100644 index 000000000..563e9a34c --- /dev/null +++ b/app/modules/asset/scheduler.server.ts @@ -0,0 +1,48 @@ +import { db } from "~/database/db.server"; +import { ShelfError } from "~/utils/error"; + +export const ASSETS_QUEUE_KEY = "assets-queue"; + +export const ASSETS_EVENT_TYPE_MAP = { + REMINDER: "REMINDER", +} as const; + +export type AssetsEventType = + (typeof ASSETS_EVENT_TYPE_MAP)[keyof typeof ASSETS_EVENT_TYPE_MAP]; + +export type AssetsSchedulerData = { + reminderId: string; + eventType: AssetsEventType; +}; + +/** + * This function is used to schedule an asset reminder. + */ +export async function scheduleAssetReminder({ + data, + when, +}: { + data: AssetsSchedulerData; + when: Date; +}) { + try { + const reference = await scheduler.sendAfter( + ASSETS_QUEUE_KEY, + data, + {}, + when + ); + + await db.assetReminder.update({ + where: { id: data.reminderId }, + data: { activeSchedulerReference: reference }, + }); + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while schedulng asset alert", + label: "Asset Scheduler", + additionalData: { ...data, when }, + }); + } +} diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index d4146dcf3..9ad95f7fc 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -90,6 +90,10 @@ import { parseFilters, parseSortingOptions, } from "./query.server"; +import { + ASSETS_EVENT_TYPE_MAP, + scheduleAssetReminder, +} from "./scheduler.server"; import type { AdvancedIndexAsset, AdvancedIndexQueryResult, @@ -2860,6 +2864,14 @@ export async function createAssetReminder({ }, }); + await scheduleAssetReminder({ + data: { + reminderId: assetReminder.id, + eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, + }, + when: alertDateTime, + }); + return assetReminder; } catch (cause) { throw new ShelfError({ diff --git a/app/modules/asset/worker.server.ts b/app/modules/asset/worker.server.ts new file mode 100644 index 000000000..b80ee1c9c --- /dev/null +++ b/app/modules/asset/worker.server.ts @@ -0,0 +1,104 @@ +import type { Prisma } from "@prisma/client"; +import type PgBoss from "pg-boss"; +import invariant from "tiny-invariant"; +import { db } from "~/database/db.server"; +import { sendEmail } from "~/emails/mail.server"; +import { ShelfError } from "~/utils/error"; +import { Logger } from "~/utils/logger"; +import { assetAlertEmailHtmlString, assetAlertEmailText } from "./emails"; +import { ASSETS_QUEUE_KEY } from "./scheduler.server"; +import type { AssetsEventType, AssetsSchedulerData } from "./scheduler.server"; + +const ASSET_REMINDER_INCLUDES_FOR_EMAIL = { + teamMembers: { + select: { + user: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + asset: { + select: { + id: true, + title: true, + mainImage: true, + mainImageExpiration: true, + }, + }, + organization: { select: { name: true } }, +} satisfies Prisma.AssetReminderInclude; + +const ASSET_SCHEDULER_EVENT_HANDLERS: Record< + AssetsEventType, + (job: PgBoss.Job) => Promise +> = { + REMINDER: async (job) => { + const reminder = await db.assetReminder + .findFirstOrThrow({ + where: { id: job.data.reminderId }, + include: ASSET_REMINDER_INCLUDES_FOR_EMAIL, + }) + .catch((cause) => { + throw new ShelfError({ + cause, + message: "Asset reminder not found", + additionalData: { ...job.data }, + label: "Asset Scheduler", + }); + }); + + const usersToSendEmail = reminder.teamMembers.map((teamMember) => { + invariant(teamMember.user, "User is not associated with teamMember."); + return teamMember.user; + }); + + /** Sending alert mails to all associated users. */ + await Promise.all( + usersToSendEmail.map((user) => + sendEmail({ + subject: "Asset Reminder Notice - Shelf", + to: user.email, + text: assetAlertEmailText({ + asset: reminder.asset, + user, + reminder, + workspaceName: reminder.organization.name, + }), + html: assetAlertEmailHtmlString({ + asset: reminder.asset, + user, + reminder, + workspaceName: reminder.organization.name, + }), + }) + ) + ); + }, +}; + +/** + * This function is used to register asset workers. + * Workers are used to process scheduled events. + */ +export async function regierAssetWorkers() { + await scheduler.work(ASSETS_QUEUE_KEY, async (job) => { + const handler = ASSET_SCHEDULER_EVENT_HANDLERS[job.data.eventType]; + + try { + await handler(job); + } catch (cause) { + Logger.error( + new ShelfError({ + cause, + message: "Something went wrong while executing scheduled work.", + additionalData: { data: job.data, work: job.data.eventType }, + label: "Asset Scheduler", + }) + ); + } + }); +} diff --git a/app/utils/error.ts b/app/utils/error.ts index 1aa17e640..0fe7c4828 100644 --- a/app/utils/error.ts +++ b/app/utils/error.ts @@ -91,7 +91,8 @@ export type FailureReason = { | "Dev error" // Error that should never happen in production because it's a developer mistake | "Environment" // Related to the environment setup | "Image Import" - | "Image Cache"; // Error related to the image import + | "Image Cache" + | "Asset Scheduler"; // Error related to the image import /** * The message intended for the user. * You can add new lines using \n which will be parsed into paragraphs in the html From fe443eab5fe99f855a9851ea1071ee84c391541e Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 20 Dec 2024 19:25:50 +0100 Subject: [PATCH 12/32] feat(asset-reminder): create function to cancel asset reminder scheduler --- app/modules/asset/scheduler.server.ts | 32 +++++++++++++++++++++++++++ app/modules/asset/service.server.ts | 3 +++ 2 files changed, 35 insertions(+) diff --git a/app/modules/asset/scheduler.server.ts b/app/modules/asset/scheduler.server.ts index 563e9a34c..36f01e032 100644 --- a/app/modules/asset/scheduler.server.ts +++ b/app/modules/asset/scheduler.server.ts @@ -1,5 +1,8 @@ +import type { AssetReminder } from "@prisma/client"; +import { isBefore } from "date-fns"; import { db } from "~/database/db.server"; import { ShelfError } from "~/utils/error"; +import { Logger } from "~/utils/logger"; export const ASSETS_QUEUE_KEY = "assets-queue"; @@ -46,3 +49,32 @@ export async function scheduleAssetReminder({ }); } } + +/** + * This function is used to cancel an asset reminder scheduler. + */ +export async function cancelAssetReminderScheduler(reminder: AssetReminder) { + try { + /** + * If the reminder is already triggered, then we don't need to cancel the scheduler. + */ + if (isBefore(reminder.alertDateTime, new Date())) { + return; + } + + if (!reminder.activeSchedulerReference) { + return; + } + + await scheduler.cancel(reminder.activeSchedulerReference); + } catch (cause) { + Logger.error( + new ShelfError({ + cause, + message: "Failed to cancel asset reminder scheduler", + additionalData: { ...reminder }, + label: "Asset Scheduler", + }) + ); + } +} diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 9ad95f7fc..ba00d4a3c 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -92,6 +92,7 @@ import { } from "./query.server"; import { ASSETS_EVENT_TYPE_MAP, + cancelAssetReminderScheduler, scheduleAssetReminder, } from "./scheduler.server"; import type { @@ -3008,6 +3009,8 @@ export async function deleteAssetReminder({ where: { id, organizationId }, }); + await cancelAssetReminderScheduler(deletedReminder); + return deletedReminder; } catch (cause) { throw new ShelfError({ From e4464f34921033e7567287765f42db3ccdfdd1b0 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 20 Dec 2024 19:31:57 +0100 Subject: [PATCH 13/32] feat(asset-reminder): rescheduling reminder on edit --- app/modules/asset/service.server.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index ba00d4a3c..3c8ffe123 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -2980,6 +2980,17 @@ export async function editAssetReminder({ }, }); + /** Reschedule Reminder */ + await cancelAssetReminderScheduler(reminder); + const when = new Date(alertDateTime); + await scheduleAssetReminder({ + data: { + reminderId: reminder.id, + eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, + }, + when, + }); + return updatedReminder; } catch (cause) { let message = "Something went wrong while editing reminder."; From 9189576a19eeb4a2812a58440e3dff252746604c Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 30 Dec 2024 12:43:17 +0100 Subject: [PATCH 14/32] feat(asset-reminder): move asset-reminder in separeate module --- .../assets/reminders/delete-reminder.tsx | 2 +- app/entry.server.tsx | 2 +- .../{asset => asset-reminder}/emails.tsx | 0 app/modules/asset-reminder/fields.ts | 18 ++ .../scheduler.server.ts | 0 app/modules/asset-reminder/service.server.ts | 217 ++++++++++++++++++ .../worker.server.ts | 0 app/modules/asset/fields.ts | 17 -- app/modules/asset/service.server.ts | 211 +---------------- .../_layout+/assets.$assetId.alerts.tsx | 4 +- app/routes/_layout+/assets.$assetId.tsx | 2 +- app/utils/error.ts | 1 + 12 files changed, 242 insertions(+), 232 deletions(-) rename app/modules/{asset => asset-reminder}/emails.tsx (100%) create mode 100644 app/modules/asset-reminder/fields.ts rename app/modules/{asset => asset-reminder}/scheduler.server.ts (100%) create mode 100644 app/modules/asset-reminder/service.server.ts rename app/modules/{asset => asset-reminder}/worker.server.ts (100%) diff --git a/app/components/assets/reminders/delete-reminder.tsx b/app/components/assets/reminders/delete-reminder.tsx index e977dee20..8b1f7c538 100644 --- a/app/components/assets/reminders/delete-reminder.tsx +++ b/app/components/assets/reminders/delete-reminder.tsx @@ -12,7 +12,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "~/components/shared/modal"; -import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; +import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; import { isFormProcessing } from "~/utils/form"; type DeleteReminderProps = { diff --git a/app/entry.server.tsx b/app/entry.server.tsx index b565a04e3..abf571a68 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -7,7 +7,7 @@ import { RemixServer } from "@remix-run/react"; import * as Sentry from "@sentry/remix"; import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; -import { regierAssetWorkers } from "./modules/asset/worker.server"; +import { regierAssetWorkers } from "./modules/asset-reminder/worker.server"; import { registerBookingWorkers } from "./modules/booking/worker.server"; import { ShelfError } from "./utils/error"; import { Logger } from "./utils/logger"; diff --git a/app/modules/asset/emails.tsx b/app/modules/asset-reminder/emails.tsx similarity index 100% rename from app/modules/asset/emails.tsx rename to app/modules/asset-reminder/emails.tsx diff --git a/app/modules/asset-reminder/fields.ts b/app/modules/asset-reminder/fields.ts new file mode 100644 index 000000000..908178a56 --- /dev/null +++ b/app/modules/asset-reminder/fields.ts @@ -0,0 +1,18 @@ +import type { Prisma } from "@prisma/client"; + +export const ASSET_REMINDER_INCLUDE_FIELDS = { + teamMembers: { + select: { + id: true, + name: true, + user: { + select: { + firstName: true, + lastName: true, + profilePicture: true, + id: true, + }, + }, + }, + }, +} satisfies Prisma.AssetReminderInclude; diff --git a/app/modules/asset/scheduler.server.ts b/app/modules/asset-reminder/scheduler.server.ts similarity index 100% rename from app/modules/asset/scheduler.server.ts rename to app/modules/asset-reminder/scheduler.server.ts diff --git a/app/modules/asset-reminder/service.server.ts b/app/modules/asset-reminder/service.server.ts new file mode 100644 index 000000000..484962cd4 --- /dev/null +++ b/app/modules/asset-reminder/service.server.ts @@ -0,0 +1,217 @@ +import type { AssetReminder, TeamMember } from "@prisma/client"; +import { db } from "~/database/db.server"; +import { updateCookieWithPerPage } from "~/utils/cookies.server"; +import { isLikeShelfError, isNotFoundError, ShelfError } from "~/utils/error"; +import { getCurrentSearchParams } from "~/utils/http.server"; +import { getParamsValues } from "~/utils/list"; +import { ASSET_REMINDER_INCLUDE_FIELDS } from "./fields"; +import { + ASSETS_EVENT_TYPE_MAP, + cancelAssetReminderScheduler, + scheduleAssetReminder, +} from "./scheduler.server"; + +const label = "Asset Reminder"; + +export async function createAssetReminder({ + name, + message, + alertDateTime, + assetId, + createdById, + organizationId, + teamMembers, +}: Pick< + AssetReminder, + | "name" + | "message" + | "alertDateTime" + | "assetId" + | "createdById" + | "organizationId" +> & { teamMembers: TeamMember["id"][] }) { + try { + await validateTeamMembersForReminder(teamMembers); + + const assetReminder = await db.assetReminder.create({ + data: { + name, + message, + alertDateTime, + assetId, + createdById, + organizationId, + teamMembers: { + connect: teamMembers.map((id) => ({ id })), + }, + }, + }); + + await scheduleAssetReminder({ + data: { + reminderId: assetReminder.id, + eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, + }, + when: alertDateTime, + }); + + return assetReminder; + } catch (cause) { + throw new ShelfError({ + cause, + message: isLikeShelfError(cause) + ? cause.message + : "Something went wrong while creating asset reminder.", + label, + additionalData: { assetId, organizationId, createdById }, + }); + } +} + +async function validateTeamMembersForReminder(teamMembers: TeamMember["id"][]) { + const teamMembersWithUserCount = await db.teamMember.count({ + where: { + id: { in: teamMembers }, + user: { isNot: null }, + }, + }); + + if (teamMembersWithUserCount !== teamMembers.length) { + throw new ShelfError({ + cause: null, + label, + message: + "Something went wrong while validating team members for reminder. Please contact support", + }); + } +} + +export async function getPaginatedAndFilterableReminders({ + assetId, + organizationId, + request, +}: Pick & { request: Request }) { + try { + const searchParams = getCurrentSearchParams(request); + const { page, perPageParam } = getParamsValues(searchParams); + const cookie = await updateCookieWithPerPage(request, perPageParam); + const { perPage } = cookie; + + const skip = page > 1 ? (page - 1) * perPage : 0; + const take = perPage >= 1 && perPage <= 100 ? perPage : 20; + + const [reminders, totalReminders] = await Promise.all([ + db.assetReminder.findMany({ + where: { assetId, organizationId }, + take, + skip, + include: ASSET_REMINDER_INCLUDE_FIELDS, + }), + db.assetReminder.count({ + where: { assetId, organizationId }, + }), + ]); + + const totalPages = Math.ceil(totalReminders / perPageParam); + + return { + reminders, + totalReminders, + page, + perPage, + totalPages, + }; + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while getting asset alerts.", + label, + }); + } +} + +export async function editAssetReminder({ + id, + name, + message, + alertDateTime, + organizationId, + teamMembers, +}: Pick< + AssetReminder, + "id" | "name" | "message" | "alertDateTime" | "organizationId" +> & { teamMembers: TeamMember["id"][] }) { + try { + await validateTeamMembersForReminder(teamMembers); + + /** This will act as a validation to check if reminder exists */ + const reminder = await db.assetReminder.findFirstOrThrow({ + where: { id, organizationId }, + }); + + const updatedReminder = await db.assetReminder.update({ + where: { id: reminder.id }, + data: { + name, + message, + alertDateTime, + teamMembers: { + set: [], // set empty so that if any team member is removed, the relation is removed + connect: teamMembers.map((id) => ({ id })), // then connect + }, + }, + }); + + /** Reschedule Reminder */ + await cancelAssetReminderScheduler(reminder); + const when = new Date(alertDateTime); + await scheduleAssetReminder({ + data: { + reminderId: reminder.id, + eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, + }, + when, + }); + + return updatedReminder; + } catch (cause) { + let message = "Something went wrong while editing reminder."; + + if (isNotFoundError(cause)) { + message = "Reminder not found or you are viewing in wrong organization."; + } + + if (isLikeShelfError(cause)) { + message = cause.message; + } + + throw new ShelfError({ + cause, + message, + label, + }); + } +} + +export async function deleteAssetReminder({ + id, + organizationId, +}: Pick) { + try { + const deletedReminder = await db.assetReminder.delete({ + where: { id, organizationId }, + }); + + await cancelAssetReminderScheduler(deletedReminder); + + return deletedReminder; + } catch (cause) { + throw new ShelfError({ + cause, + message: isNotFoundError(cause) + ? "Reminder not found or you are viewing in wrong organization." + : "Something went wrong while deleting reminder.", + label, + }); + } +} diff --git a/app/modules/asset/worker.server.ts b/app/modules/asset-reminder/worker.server.ts similarity index 100% rename from app/modules/asset/worker.server.ts rename to app/modules/asset-reminder/worker.server.ts diff --git a/app/modules/asset/fields.ts b/app/modules/asset/fields.ts index 9bffe917a..7aaa2d27e 100644 --- a/app/modules/asset/fields.ts +++ b/app/modules/asset/fields.ts @@ -220,20 +220,3 @@ export const advancedAssetIndexFields = () => { return fields; }; - -export const ASSET_REMINDER_INCLUDE_FIELDS = { - teamMembers: { - select: { - id: true, - name: true, - user: { - select: { - firstName: true, - lastName: true, - profilePicture: true, - id: true, - }, - }, - }, - }, -} satisfies Prisma.AssetReminderInclude; diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 3c8ffe123..a494c9440 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -12,7 +12,6 @@ import type { Kit, AssetIndexSettings, UserOrganization, - AssetReminder, } from "@prisma/client"; import { AssetStatus, @@ -80,7 +79,7 @@ import { } from "~/utils/storage.server"; import { resolveTeamMemberName } from "~/utils/user"; -import { ASSET_REMINDER_INCLUDE_FIELDS, assetIndexFields } from "./fields"; +import { assetIndexFields } from "./fields"; import { assetQueryFragment, assetQueryJoins, @@ -90,11 +89,6 @@ import { parseFilters, parseSortingOptions, } from "./query.server"; -import { - ASSETS_EVENT_TYPE_MAP, - cancelAssetReminderScheduler, - scheduleAssetReminder, -} from "./scheduler.server"; import type { AdvancedIndexAsset, AdvancedIndexQueryResult, @@ -2830,206 +2824,3 @@ export async function getAssetsTabLoaderData({ }); } } - -export async function createAssetReminder({ - name, - message, - alertDateTime, - assetId, - createdById, - organizationId, - teamMembers, -}: Pick< - AssetReminder, - | "name" - | "message" - | "alertDateTime" - | "assetId" - | "createdById" - | "organizationId" -> & { teamMembers: TeamMember["id"][] }) { - try { - await validateTeamMembersForReminder(teamMembers); - - const assetReminder = await db.assetReminder.create({ - data: { - name, - message, - alertDateTime, - assetId, - createdById, - organizationId, - teamMembers: { - connect: teamMembers.map((id) => ({ id })), - }, - }, - }); - - await scheduleAssetReminder({ - data: { - reminderId: assetReminder.id, - eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, - }, - when: alertDateTime, - }); - - return assetReminder; - } catch (cause) { - throw new ShelfError({ - cause, - message: isLikeShelfError(cause) - ? cause.message - : "Something went wrong while creating asset reminder.", - label, - additionalData: { assetId, organizationId, createdById }, - }); - } -} - -async function validateTeamMembersForReminder(teamMembers: TeamMember["id"][]) { - const teamMembersWithUserCount = await db.teamMember.count({ - where: { - id: { in: teamMembers }, - user: { isNot: null }, - }, - }); - - if (teamMembersWithUserCount !== teamMembers.length) { - throw new ShelfError({ - cause: null, - label, - message: - "Something went wrong while validating team members for reminder. Please contact support", - }); - } -} - -export async function getPaginatedAndFilterableReminders({ - assetId, - organizationId, - request, -}: Pick & { request: Request }) { - try { - const searchParams = getCurrentSearchParams(request); - const { page, perPageParam } = getParamsValues(searchParams); - const cookie = await updateCookieWithPerPage(request, perPageParam); - const { perPage } = cookie; - - const skip = page > 1 ? (page - 1) * perPage : 0; - const take = perPage >= 1 && perPage <= 100 ? perPage : 20; - - const [reminders, totalReminders] = await Promise.all([ - db.assetReminder.findMany({ - where: { assetId, organizationId }, - take, - skip, - include: ASSET_REMINDER_INCLUDE_FIELDS, - }), - db.assetReminder.count({ - where: { assetId, organizationId }, - }), - ]); - - const totalPages = Math.ceil(totalReminders / perPageParam); - - return { - reminders, - totalReminders, - page, - perPage, - totalPages, - }; - } catch (cause) { - throw new ShelfError({ - cause, - message: "Something went wrong while getting asset alerts.", - label, - }); - } -} - -export async function editAssetReminder({ - id, - name, - message, - alertDateTime, - organizationId, - teamMembers, -}: Pick< - AssetReminder, - "id" | "name" | "message" | "alertDateTime" | "organizationId" -> & { teamMembers: TeamMember["id"][] }) { - try { - await validateTeamMembersForReminder(teamMembers); - - /** This will act as a validation to check if reminder exists */ - const reminder = await db.assetReminder.findFirstOrThrow({ - where: { id, organizationId }, - }); - - const updatedReminder = await db.assetReminder.update({ - where: { id: reminder.id }, - data: { - name, - message, - alertDateTime, - teamMembers: { - set: [], // set empty so that if any team member is removed, the relation is removed - connect: teamMembers.map((id) => ({ id })), // then connect - }, - }, - }); - - /** Reschedule Reminder */ - await cancelAssetReminderScheduler(reminder); - const when = new Date(alertDateTime); - await scheduleAssetReminder({ - data: { - reminderId: reminder.id, - eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, - }, - when, - }); - - return updatedReminder; - } catch (cause) { - let message = "Something went wrong while editing reminder."; - - if (isNotFoundError(cause)) { - message = "Reminder not found or you are viewing in wrong organization."; - } - - if (isLikeShelfError(cause)) { - message = cause.message; - } - - throw new ShelfError({ - cause, - message, - label, - }); - } -} - -export async function deleteAssetReminder({ - id, - organizationId, -}: Pick) { - try { - const deletedReminder = await db.assetReminder.delete({ - where: { id, organizationId }, - }); - - await cancelAssetReminderScheduler(deletedReminder); - - return deletedReminder; - } catch (cause) { - throw new ShelfError({ - cause, - message: isNotFoundError(cause) - ? "Reminder not found or you are viewing in wrong organization." - : "Something went wrong while deleting reminder.", - label, - }); - } -} diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 035622a79..3ec44feb6 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -13,12 +13,12 @@ import { TooltipTrigger, } from "~/components/shared/tooltip"; import { Td, Th } from "~/components/table"; -import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; +import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; import { deleteAssetReminder, editAssetReminder, getPaginatedAndFilterableReminders, -} from "~/modules/asset/service.server"; +} from "~/modules/asset-reminder/service.server"; import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; import { getDateTimeFormat } from "~/utils/client-hints"; diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index f933f9c56..99f48e9a0 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -20,12 +20,12 @@ import HorizontalTabs from "~/components/layout/horizontal-tabs"; import When from "~/components/when/when"; import { useUserRoleHelper } from "~/hooks/user-user-role-helper"; import { - createAssetReminder, deleteAsset, deleteOtherImages, getAsset, relinkQrCode, } from "~/modules/asset/service.server"; +import { createAssetReminder } from "~/modules/asset-reminder/service.server"; import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; import assetCss from "~/styles/asset.css?url"; diff --git a/app/utils/error.ts b/app/utils/error.ts index 0fe7c4828..0a81142f6 100644 --- a/app/utils/error.ts +++ b/app/utils/error.ts @@ -92,6 +92,7 @@ export type FailureReason = { | "Environment" // Related to the environment setup | "Image Import" | "Image Cache" + | "Asset Reminder" | "Asset Scheduler"; // Error related to the image import /** * The message intended for the user. From 7b830c470bd8da77247628ad2b2cdc4304e523fe Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 30 Dec 2024 12:47:17 +0100 Subject: [PATCH 15/32] feat(asset-reminder): move asset-reminder in separeate module --- .../{assets/reminders => asset-reminder}/actions-dropdown.tsx | 2 +- .../{assets/reminders => asset-reminder}/delete-reminder.tsx | 0 .../set-or-edit-reminder-dialog.tsx | 2 +- .../reminders => asset-reminder}/team-members-selector.tsx | 0 app/components/assets/actions-dropdown.tsx | 2 +- app/routes/_layout+/assets.$assetId.alerts.tsx | 4 ++-- app/routes/_layout+/assets.$assetId.tsx | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename app/components/{assets/reminders => asset-reminder}/actions-dropdown.tsx (99%) rename app/components/{assets/reminders => asset-reminder}/delete-reminder.tsx (100%) rename app/components/{assets/reminders => asset-reminder}/set-or-edit-reminder-dialog.tsx (98%) rename app/components/{assets/reminders => asset-reminder}/team-members-selector.tsx (100%) diff --git a/app/components/assets/reminders/actions-dropdown.tsx b/app/components/asset-reminder/actions-dropdown.tsx similarity index 99% rename from app/components/assets/reminders/actions-dropdown.tsx rename to app/components/asset-reminder/actions-dropdown.tsx index 5d64bc562..6ca71a7cd 100644 --- a/app/components/assets/reminders/actions-dropdown.tsx +++ b/app/components/asset-reminder/actions-dropdown.tsx @@ -9,7 +9,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "~/components/shared/dropdown"; -import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset/fields"; +import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; import DeleteReminder from "./delete-reminder"; import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog"; diff --git a/app/components/assets/reminders/delete-reminder.tsx b/app/components/asset-reminder/delete-reminder.tsx similarity index 100% rename from app/components/assets/reminders/delete-reminder.tsx rename to app/components/asset-reminder/delete-reminder.tsx diff --git a/app/components/assets/reminders/set-or-edit-reminder-dialog.tsx b/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx similarity index 98% rename from app/components/assets/reminders/set-or-edit-reminder-dialog.tsx rename to app/components/asset-reminder/set-or-edit-reminder-dialog.tsx index 6320685f6..ba1d22f2a 100644 --- a/app/components/assets/reminders/set-or-edit-reminder-dialog.tsx +++ b/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx @@ -9,7 +9,7 @@ import { Separator } from "~/components/shared/separator"; import { dateForDateTimeInputValue } from "~/utils/date-fns"; import { isFormProcessing } from "~/utils/form"; import TeamMembersSelector from "./team-members-selector"; -import { Dialog, DialogPortal } from "../../layout/dialog"; +import { Dialog, DialogPortal } from "../layout/dialog"; export const setReminderSchema = z.object({ name: z.string().min(1, "Please enter name."), diff --git a/app/components/assets/reminders/team-members-selector.tsx b/app/components/asset-reminder/team-members-selector.tsx similarity index 100% rename from app/components/assets/reminders/team-members-selector.tsx rename to app/components/asset-reminder/team-members-selector.tsx diff --git a/app/components/assets/actions-dropdown.tsx b/app/components/assets/actions-dropdown.tsx index 43ede3b47..76b2658d6 100644 --- a/app/components/assets/actions-dropdown.tsx +++ b/app/components/assets/actions-dropdown.tsx @@ -20,8 +20,8 @@ import { userHasPermission } from "~/utils/permissions/permission.validator.clie import { tw } from "~/utils/tw"; import { DeleteAsset } from "./delete-asset"; import RelinkQrCodeDialog from "./relink-qr-code-dialog"; -import SetOrEditReminderDialog from "./reminders/set-or-edit-reminder-dialog"; import { UpdateGpsCoordinatesForm } from "./update-gps-coordinates-form"; +import SetOrEditReminderDialog from "../asset-reminder/set-or-edit-reminder-dialog"; import Icon from "../icons/icon"; import { Button } from "../shared/button"; import When from "../when/when"; diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 3ec44feb6..647cc7e8d 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -2,8 +2,8 @@ import type { Prisma } from "@prisma/client"; import { json } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { z } from "zod"; -import ActionsDropdown from "~/components/assets/reminders/actions-dropdown"; -import { setReminderSchema } from "~/components/assets/reminders/set-or-edit-reminder-dialog"; +import ActionsDropdown from "~/components/asset-reminder/actions-dropdown"; +import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog"; import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 99f48e9a0..61ee974ef 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -8,11 +8,11 @@ import { redirect, json } from "@remix-run/node"; import { useLoaderData, Outlet } from "@remix-run/react"; import mapCss from "maplibre-gl/dist/maplibre-gl.css?url"; import { z } from "zod"; +import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog"; import ActionsDropdown from "~/components/assets/actions-dropdown"; import { AssetImage } from "~/components/assets/asset-image"; import { AssetStatusBadge } from "~/components/assets/asset-status-badge"; import BookingActionsDropdown from "~/components/assets/booking-actions-dropdown"; -import { setReminderSchema } from "~/components/assets/reminders/set-or-edit-reminder-dialog"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; From 400c98b086a3834ff86d3d4a46a289835346709e Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 30 Dec 2024 13:27:53 +0100 Subject: [PATCH 16/32] fix(migration): remove DROP INDEX from asset reminder migration file --- .../migration.sql | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/app/database/migrations/20241217150402_create_schema_for_asset_reminders/migration.sql b/app/database/migrations/20241217150402_create_schema_for_asset_reminders/migration.sql index af2e25564..06a0362bf 100644 --- a/app/database/migrations/20241217150402_create_schema_for_asset_reminders/migration.sql +++ b/app/database/migrations/20241217150402_create_schema_for_asset_reminders/migration.sql @@ -1,39 +1,3 @@ --- DropIndex -DROP INDEX "Asset_categoryId_organizationId_idx"; - --- DropIndex -DROP INDEX "Asset_createdAt_organizationId_idx"; - --- DropIndex -DROP INDEX "Asset_kitId_organizationId_idx"; - --- DropIndex -DROP INDEX "Asset_locationId_organizationId_idx"; - --- DropIndex -DROP INDEX "Asset_organizationId_compound_idx"; - --- DropIndex -DROP INDEX "Asset_status_organizationId_idx"; - --- DropIndex -DROP INDEX "Asset_valuation_organizationId_idx"; - --- DropIndex -DROP INDEX "AssetCustomFieldValue_lookup_idx"; - --- DropIndex -DROP INDEX "Custody_assetId_teamMemberId_idx"; - --- DropIndex -DROP INDEX "Qr_assetId_idx"; - --- DropIndex -DROP INDEX "_AssetToBooking_Asset_idx"; - --- DropIndex -DROP INDEX "_AssetToTag_asset_idx"; - -- CreateTable CREATE TABLE "AssetReminder" ( "id" TEXT NOT NULL, From 810e93c4c5ed3e688982c0e988bdb0842871a731 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 30 Dec 2024 16:55:04 +0100 Subject: [PATCH 17/32] feat(asset-reminder): create card to show 2 reminder on asset overview page --- app/components/assets/asset-alert-cards.tsx | 82 +++++++++++++++++++ app/modules/asset-reminder/service.server.ts | 22 +++++ .../_layout+/assets.$assetId.overview.tsx | 10 +++ 3 files changed, 114 insertions(+) create mode 100644 app/components/assets/asset-alert-cards.tsx diff --git a/app/components/assets/asset-alert-cards.tsx b/app/components/assets/asset-alert-cards.tsx new file mode 100644 index 000000000..30d2e1a5e --- /dev/null +++ b/app/components/assets/asset-alert-cards.tsx @@ -0,0 +1,82 @@ +import { useLoaderData } from "@remix-run/react"; +import { EllipsisIcon } from "lucide-react"; +import { type loader } from "~/routes/_layout+/assets.$assetId.overview"; +import { tw } from "~/utils/tw"; +import { resolveTeamMemberName } from "~/utils/user"; +import { Button } from "../shared/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../shared/tooltip"; +import When from "../when/when"; + +type AssetAlertCardsProps = { + className?: string; + style?: React.CSSProperties; +}; + +export default function AssetAlertCards({ + className, + style, +}: AssetAlertCardsProps) { + const { alerts } = useLoaderData(); + + if (!alerts.length) { + return; + } + + return ( +
+
+
Reminders
+ + +
+ + {alerts.map((alert) => { + const slicedTeamMembers = alert.teamMembers.slice(0, 10); + const remainingTeamMembers = + alert.teamMembers.length - slicedTeamMembers.length; + + return ( +
+
{alert.name}
+

{alert.message}

+ +
+ {slicedTeamMembers.map((teamMember) => ( + + + + {teamMember.name} + + + {resolveTeamMemberName(teamMember)} + + + + ))} + + 0}> +
+ +{remainingTeamMembers} +
+
+
+
+ ); + })} +
+ ); +} diff --git a/app/modules/asset-reminder/service.server.ts b/app/modules/asset-reminder/service.server.ts index 484962cd4..f1b70c303 100644 --- a/app/modules/asset-reminder/service.server.ts +++ b/app/modules/asset-reminder/service.server.ts @@ -215,3 +215,25 @@ export async function deleteAssetReminder({ }); } } + +export async function getRemindersForOverviewPage({ + assetId, + organizationId, +}: { + assetId: AssetReminder["assetId"]; + organizationId: AssetReminder["organizationId"]; +}) { + try { + return await db.assetReminder.findMany({ + where: { assetId, organizationId }, + take: 2, + include: ASSET_REMINDER_INCLUDE_FIELDS, + }); + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while getting asset alerts.", + label, + }); + } +} diff --git a/app/routes/_layout+/assets.$assetId.overview.tsx b/app/routes/_layout+/assets.$assetId.overview.tsx index 00bb54aa8..d03b37257 100644 --- a/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/app/routes/_layout+/assets.$assetId.overview.tsx @@ -9,6 +9,7 @@ import { json } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import { useZorm } from "react-zorm"; import { z } from "zod"; +import AssetAlertCards from "~/components/assets/asset-alert-cards"; import { CustodyCard } from "~/components/assets/asset-custody-card"; import { Switch } from "~/components/forms/switch"; import Icon from "~/components/icons/icon"; @@ -33,6 +34,7 @@ import { updateAssetBookingAvailability, } from "~/modules/asset/service.server"; import type { ShelfAssetCustomFieldValueType } from "~/modules/asset/types"; +import { getRemindersForOverviewPage } from "~/modules/asset-reminder/service.server"; import { generateQrObj } from "~/modules/qr/utils.server"; import { getScanByQrId } from "~/modules/scan/service.server"; @@ -119,6 +121,11 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { organizationId, }); + const alerts = await getRemindersForOverviewPage({ + assetId: id, + organizationId, + }); + const booking = asset.bookings.length > 0 ? asset.bookings[0] : undefined; let currentBooking: any = null; @@ -164,6 +171,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { locale, timeZone, qrObj, + alerts, }) ); } catch (cause) { @@ -449,6 +457,8 @@ export default function AssetOverview() { + + {asset?.kit?.name ? (
From 57b9ac87d29952c70b91a56fb0fe07a21ad4af4a Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 3 Jan 2025 14:25:06 +0100 Subject: [PATCH 18/32] feat(asset-reminder): update how we handle alertDateTime in AssetReminder --- app/components/assets/asset-alert-cards.tsx | 4 ++-- app/database/schema.prisma | 4 +--- app/routes/_layout+/assets.$assetId.tsx | 15 ++++++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/components/assets/asset-alert-cards.tsx b/app/components/assets/asset-alert-cards.tsx index 30d2e1a5e..29cdfbf9b 100644 --- a/app/components/assets/asset-alert-cards.tsx +++ b/app/components/assets/asset-alert-cards.tsx @@ -32,7 +32,7 @@ export default function AssetAlertCards({
Reminders
-
@@ -43,7 +43,7 @@ export default function AssetAlertCards({ alert.teamMembers.length - slicedTeamMembers.length; return ( -
+
{alert.name}

{alert.message}

diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 3f663ceaa..81816416b 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -117,10 +117,8 @@ model Asset { bookings Booking[] reminders AssetReminder[] - // Special GIN index for optimization of simple search queries @@index([title(ops: raw("gin_trgm_ops")), description(ops: raw("gin_trgm_ops"))], type: Gin) - // Indexes for optimization of queries @@index([organizationId, title, status, availableToBook], name: "Asset_organizationId_compound_idx") @@index([status, organizationId], name: "Asset_status_organizationId_idx") @@ -396,7 +394,7 @@ model TeamMember { deletedAt DateTime? bookings Booking[] assetReminders AssetReminder[] - + // Special GIN index for optimization of simple search queries @@index([name(ops: raw("gin_trgm_ops"))], type: Gin) } diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 61ee974ef..12e664528 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -6,6 +6,7 @@ import type { } from "@remix-run/node"; import { redirect, json } from "@remix-run/node"; import { useLoaderData, Outlet } from "@remix-run/react"; +import { DateTime } from "luxon"; import mapCss from "maplibre-gl/dist/maplibre-gl.css?url"; import { z } from "zod"; import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog"; @@ -31,7 +32,7 @@ import assetCss from "~/styles/asset.css?url"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; -import { getDateTimeFormat } from "~/utils/client-hints"; +import { getDateTimeFormat, getHints } from "~/utils/client-hints"; import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError } from "~/utils/error"; import { error, getParams, data, parseData } from "~/utils/http.server"; @@ -192,10 +193,22 @@ export async function action({ context, request, params }: ActionFunctionArgs) { case "set-reminder": { const payload = parseData(formData, setReminderSchema); + const hints = getHints(request); + + const fmt = "yyyy-MM-dd'T'HH:mm"; + + const alertDateTime = DateTime.fromFormat( + formData.get("alertDateTime")!.toString()!, + fmt, + { + zone: hints.timeZone, + } + ).toJSDate(); await createAssetReminder({ ...payload, assetId: id, + alertDateTime, organizationId, createdById: userId, }); From 131b9e038f49310fc8df80c894e3c35437bce250 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 3 Jan 2025 15:34:00 +0100 Subject: [PATCH 19/32] feat(assets-reminder): showing owner and admin users in TeamMember --- .../asset-reminder/team-members-selector.tsx | 2 +- app/routes/_layout+/assets.$assetId.tsx | 16 ++++++++++++++- app/routes/api+/model-filters.ts | 20 ++++++++++++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/components/asset-reminder/team-members-selector.tsx b/app/components/asset-reminder/team-members-selector.tsx index 269b6854a..18092e374 100644 --- a/app/components/asset-reminder/team-members-selector.tsx +++ b/app/components/asset-reminder/team-members-selector.tsx @@ -31,7 +31,7 @@ export default function TeamMembersSelector({ name: "teamMember", queryKey: "name", deletedAt: null, - userIsNotNull: true, + userWithAdminAndOwnerOnly: true, }, countKey: "totalTeamMembers", initialDataKey: "teamMembers", diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 12e664528..953569ca2 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -84,7 +84,21 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { request, organizationId, where: { - user: { isNot: null }, + AND: [ + { user: { isNot: null } }, + { + user: { + userOrganizations: { + some: { + AND: [ + { organizationId }, + { roles: { hasSome: ["ADMIN", "OWNER"] } }, + ], + }, + }, + }, + }, + ], }, }); diff --git a/app/routes/api+/model-filters.ts b/app/routes/api+/model-filters.ts index d2f262d9b..a690ad5d1 100644 --- a/app/routes/api+/model-filters.ts +++ b/app/routes/api+/model-filters.ts @@ -40,7 +40,7 @@ export const ModelFiltersSchema = z.discriminatedUnion("name", [ BasicModelFilters.extend({ name: z.literal("teamMember"), deletedAt: z.string().nullable().optional(), - userIsNotNull: z.coerce.boolean().optional(), // To get only the teamMember which have a user associated + userWithAdminAndOwnerOnly: z.coerce.boolean().optional(), // To get only the teamMembers which are admin or owner }), BasicModelFilters.extend({ name: z.literal("booking"), @@ -94,8 +94,22 @@ export async function loader({ context, request }: LoaderFunctionArgs) { ); where.deletedAt = modelFilters.deletedAt; - if (modelFilters.userIsNotNull) { - where.user = { isNot: null }; + if (modelFilters.userWithAdminAndOwnerOnly) { + where.AND = [ + { user: { isNot: null } }, + { + user: { + userOrganizations: { + some: { + AND: [ + { organizationId }, + { roles: { hasSome: ["ADMIN", "OWNER"] } }, + ], + }, + }, + }, + }, + ]; } } else { where.OR.push({ From 35ccba9ca81f78f92f59f60c5e4098fd39f6c292 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Fri, 3 Jan 2025 15:40:46 +0100 Subject: [PATCH 20/32] feat(asset-reminder): add status column in table --- app/routes/_layout+/assets.$assetId.alerts.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 647cc7e8d..6493997fe 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -176,6 +176,7 @@ export default function AssetAlerts() { <> Message Alert Date + Status Users } @@ -190,11 +191,16 @@ function ListContent({ include: typeof ASSET_REMINDER_INCLUDE_FIELDS; }> & { displayDate: string }; }) { + const now = new Date(); + const status = + now < new Date(item.alertDateTime) ? "Pending" : "Reminder sent"; + return ( <> {item.name} {item.message} {item.displayDate} + {status} {item.teamMembers.map((teamMember) => ( From 506896397bce6370c5efcf640681f32454c19519 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 6 Jan 2025 18:19:17 +0100 Subject: [PATCH 21/32] feat(asset-reminder): fix scheduler reference error --- app/modules/asset-reminder/scheduler.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/modules/asset-reminder/scheduler.server.ts b/app/modules/asset-reminder/scheduler.server.ts index 36f01e032..7e6710f12 100644 --- a/app/modules/asset-reminder/scheduler.server.ts +++ b/app/modules/asset-reminder/scheduler.server.ts @@ -3,6 +3,7 @@ import { isBefore } from "date-fns"; import { db } from "~/database/db.server"; import { ShelfError } from "~/utils/error"; import { Logger } from "~/utils/logger"; +import { scheduler } from "~/utils/scheduler.server"; export const ASSETS_QUEUE_KEY = "assets-queue"; From 9d438debd8da01e8d6d7598eba6bc4f2f4e3ea86 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 6 Jan 2025 18:35:30 +0100 Subject: [PATCH 22/32] feat(asset-reminder): allow edit for pending reminder only, fix forwardRef issue with DeleteReminder --- .../asset-reminder/actions-dropdown.tsx | 40 ++++---- .../asset-reminder/delete-reminder.tsx | 93 ++++++++++--------- app/modules/asset-reminder/service.server.ts | 11 +++ 3 files changed, 85 insertions(+), 59 deletions(-) diff --git a/app/components/asset-reminder/actions-dropdown.tsx b/app/components/asset-reminder/actions-dropdown.tsx index 6ca71a7cd..99709ad59 100644 --- a/app/components/asset-reminder/actions-dropdown.tsx +++ b/app/components/asset-reminder/actions-dropdown.tsx @@ -12,6 +12,7 @@ import { import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; import DeleteReminder from "./delete-reminder"; import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog"; +import When from "../when/when"; type ActionsDropdownProps = { reminder: Prisma.AssetReminderGetPayload<{ @@ -23,6 +24,9 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) { const [isDropdownOpem, setIsDropdownOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const now = new Date(); + const isPending = now < new Date(reminder.alertDateTime); + return ( - - - + + + + + diff --git a/app/components/asset-reminder/delete-reminder.tsx b/app/components/asset-reminder/delete-reminder.tsx index 8b1f7c538..367ce10d8 100644 --- a/app/components/asset-reminder/delete-reminder.tsx +++ b/app/components/asset-reminder/delete-reminder.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from "react"; import type { Prisma } from "@prisma/client"; import { Form, useNavigation } from "@remix-run/react"; import { TrashIcon } from "lucide-react"; @@ -21,49 +22,57 @@ type DeleteReminderProps = { }>; }; -export default function DeleteReminder({ reminder }: DeleteReminderProps) { - const navigation = useNavigation(); - const disabled = isFormProcessing(navigation.state); +const DeleteReminder = forwardRef( + function ({ reminder }, ref) { + const navigation = useNavigation(); + const disabled = isFormProcessing(navigation.state); - return ( - - - Delete - + return ( + + + Delete + - - -
- - - -
- Delete {reminder.name} - - Are you sure you want to delete this reminder? This action cannot be - undone. - -
- -
- - - + + +
+ + + +
+ Delete {reminder.name} + + Are you sure you want to delete this reminder? This action cannot + be undone. + +
+ +
+ + + -
- - + + + - -
-
-
-
- - ); -} + + +
+
+
+
+ ); + } +); + +DeleteReminder.displayName = "DeleteReminder"; +export default DeleteReminder; diff --git a/app/modules/asset-reminder/service.server.ts b/app/modules/asset-reminder/service.server.ts index f1b70c303..8eb3ed758 100644 --- a/app/modules/asset-reminder/service.server.ts +++ b/app/modules/asset-reminder/service.server.ts @@ -149,6 +149,17 @@ export async function editAssetReminder({ where: { id, organizationId }, }); + const now = new Date(); + if (now > reminder.alertDateTime) { + throw new ShelfError({ + cause: null, + message: "Edit is not allowed for this reminder.", + label: "Asset Reminder", + additionalData: { id }, + shouldBeCaptured: false, + }); + } + const updatedReminder = await db.assetReminder.update({ where: { id: reminder.id }, data: { From 5ade3d6783bb908e37a9d768823c3b9a7423cdf9 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 6 Jan 2025 18:43:17 +0100 Subject: [PATCH 23/32] feat(asset-reminder): fix issue with alertDateTime on edit --- app/routes/_layout+/assets.$assetId.alerts.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 6493997fe..d78ac929b 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { json } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { DateTime } from "luxon"; import { z } from "zod"; import ActionsDropdown from "~/components/asset-reminder/actions-dropdown"; import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog"; @@ -21,7 +22,7 @@ import { } from "~/modules/asset-reminder/service.server"; import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; -import { getDateTimeFormat } from "~/utils/client-hints"; +import { getDateTimeFormat, getHints } from "~/utils/client-hints"; import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError } from "~/utils/error"; import { data, error, getParams, parseData } from "~/utils/http.server"; @@ -122,12 +123,21 @@ export async function action({ context, request }: ActionFunctionArgs) { setReminderSchema.extend({ id: z.string() }) ); + const hints = getHints(request); + const fmt = "yyyy-MM-dd'T'HH:mm"; + + const alertDateTime = DateTime.fromFormat( + formData.get("alertDateTime")!.toString()!, + fmt, + { zone: hints.timeZone } + ).toJSDate(); + await editAssetReminder({ id: payload.id, name: payload.name, message: payload.message, - alertDateTime: payload.alertDateTime, teamMembers: payload.teamMembers, + alertDateTime, organizationId, }); From b64ece9eedc79f62d6bad38e6e60c68001de18eb Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 6 Jan 2025 19:43:31 +0100 Subject: [PATCH 24/32] feat(asset-reminder): create separate API for fetching teamMembers for alerts --- .../asset-reminder/team-members-selector.tsx | 133 +++++++++++------- app/hooks/use-api-query.ts | 47 +++++++ app/routes/_layout+/assets.$assetId.tsx | 27 ---- app/routes/api+/alerts.team-members.ts | 70 +++++++++ 4 files changed, 196 insertions(+), 81 deletions(-) create mode 100644 app/hooks/use-api-query.ts create mode 100644 app/routes/api+/alerts.team-members.ts diff --git a/app/components/asset-reminder/team-members-selector.tsx b/app/components/asset-reminder/team-members-selector.tsx index 18092e374..e52439c21 100644 --- a/app/components/asset-reminder/team-members-selector.tsx +++ b/app/components/asset-reminder/team-members-selector.tsx @@ -1,8 +1,9 @@ -import type { Prisma } from "@prisma/client"; +import { useMemo, useState } from "react"; import { CheckIcon, UserIcon } from "lucide-react"; import { Separator } from "~/components/shared/separator"; import When from "~/components/when/when"; -import { useModelFilters } from "~/hooks/use-model-filters"; +import useApiQuery from "~/hooks/use-api-query"; +import type { AlertTeamMember } from "~/routes/api+/alerts.team-members"; import { tw } from "~/utils/tw"; type TeamMembersSelectorProps = { @@ -18,25 +19,35 @@ export default function TeamMembersSelector({ error, defaultValues, }: TeamMembersSelectorProps) { - const { - items, - handleSearchQueryChange, - searchQuery, - handleSelectItemChange, - selectedItems, - } = useModelFilters({ - selectionMode: "none", - defaultValues, - model: { - name: "teamMember", - queryKey: "name", - deletedAt: null, - userWithAdminAndOwnerOnly: true, - }, - countKey: "totalTeamMembers", - initialDataKey: "teamMembers", + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTeamMembers, setSelectedTeamMembers] = useState( + defaultValues?.length ? defaultValues : [] + ); + + const { isLoading, data } = useApiQuery<{ + teamMembers: AlertTeamMember[]; + }>({ + api: "/api/alerts/team-members", }); + const teamMembers = useMemo(() => { + if (!data) { + return []; + } + + if (!searchQuery) { + return data.teamMembers; + } + + const normalizedQuery = searchQuery.toLowerCase().trim(); + return data.teamMembers.filter( + (tm) => + tm.name.toLowerCase().includes(normalizedQuery) || + tm.user?.firstName?.toLowerCase().includes(normalizedQuery) || + tm.user?.lastName?.toLowerCase().includes(normalizedQuery) + ); + }, [data, searchQuery]); + return (
{ + setSearchQuery(event.target.value); + }} />
@@ -58,7 +71,7 @@ export default function TeamMembersSelector({ - {selectedItems.map((item, i) => ( + {selectedTeamMembers.map((item, i) => ( ))} - {items.map((item) => { - const teamMember = item as unknown as Prisma.TeamMemberGetPayload<{ - include: { user: { select: { profilePicture: true } } }; - }>; - const isTeamMemberSelected = selectedItems.includes(teamMember.id); + + {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} + - return ( -
{ - handleSelectItemChange(teamMember.id); - }} - > -
- {`${teamMember.name}'s -

{teamMember.name}

-
+ + {teamMembers.map((teamMember) => { + const isTeamMemberSelected = selectedTeamMembers.includes( + teamMember.id + ); + + return ( +
{ + setSelectedTeamMembers((prev) => { + if (prev.includes(teamMember.id)) { + return prev.filter((tm) => tm !== teamMember.id); + } + return [...prev, teamMember.id]; + }); + }} + > +
+ {`${teamMember.name}'s +

{teamMember.name}

+
- - - -
- ); - })} + + + +
+ ); + })} +
); } diff --git a/app/hooks/use-api-query.ts b/app/hooks/use-api-query.ts new file mode 100644 index 000000000..ef4f10a64 --- /dev/null +++ b/app/hooks/use-api-query.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +/** + * A simple hook which calls any of our API + * + */ +type UseApiQueryParams = { + /** Any API endpoint */ + api: string; + /** Query will not execute until this is true */ + enabled?: boolean; +}; + +export default function useApiQuery({ + api, + enabled = true, +}: UseApiQueryParams) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + const [data, setData] = useState(); + + useEffect( + function handleQuery() { + if (enabled) { + setIsLoading(true); + fetch(api) + .then((response) => response.json()) + .then((data: TData) => { + setData(data); + }) + .catch((error: Error) => { + setError(error?.message ?? "Something went wrong."); + }) + .finally(() => { + setIsLoading(false); + }); + } + }, + [api, enabled] + ); + + return { + isLoading, + error, + data, + }; +} diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 953569ca2..f8216ce32 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -27,7 +27,6 @@ import { relinkQrCode, } from "~/modules/asset/service.server"; import { createAssetReminder } from "~/modules/asset-reminder/service.server"; -import { getPaginatedAndFilterableTeamMembers } from "~/modules/team-member/service.server"; import assetCss from "~/styles/asset.css?url"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; @@ -78,30 +77,6 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }, }); - /** We need teamMembers in SetReminderForm */ - const { teamMembers, totalTeamMembers } = - await getPaginatedAndFilterableTeamMembers({ - request, - organizationId, - where: { - AND: [ - { user: { isNot: null } }, - { - user: { - userOrganizations: { - some: { - AND: [ - { organizationId }, - { roles: { hasSome: ["ADMIN", "OWNER"] } }, - ], - }, - }, - }, - }, - ], - }, - }); - const header: HeaderData = { title: asset.title, }; @@ -116,8 +91,6 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { }).format(asset.createdAt), }, header, - teamMembers, - totalTeamMembers, }) ); } catch (cause) { diff --git a/app/routes/api+/alerts.team-members.ts b/app/routes/api+/alerts.team-members.ts new file mode 100644 index 000000000..c31a7c91f --- /dev/null +++ b/app/routes/api+/alerts.team-members.ts @@ -0,0 +1,70 @@ +import type { Prisma } from "@prisma/client"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { db } from "~/database/db.server"; +import { makeShelfError } from "~/utils/error"; +import { data, error } from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; + +const TEAM_MEMBER_INCLUDE = { + custodies: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + profilePicture: true, + }, + }, +} satisfies Prisma.TeamMemberInclude; + +export type AlertTeamMember = Prisma.TeamMemberGetPayload<{ + include: typeof TEAM_MEMBER_INCLUDE; +}>; + +export async function loader({ context, request }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.teamMember, + action: PermissionAction.read, + }); + + const teamMembers = await db.teamMember.findMany({ + where: { + deletedAt: null, + organizationId, + AND: [ + { user: { isNot: null } }, + { + user: { + userOrganizations: { + some: { + AND: [ + { organizationId }, + { roles: { hasSome: ["ADMIN", "OWNER"] } }, + ], + }, + }, + }, + }, + ], + }, + orderBy: { createdAt: "desc" }, + include: TEAM_MEMBER_INCLUDE, + }); + + return json(data({ teamMembers })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} From 954ff5c278093cef4dfaa4c542b9509da5477742 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Mon, 6 Jan 2025 20:00:40 +0100 Subject: [PATCH 25/32] feat(asset-reminder): add badge for status column in alerts table --- app/routes/_layout+/assets.$assetId.alerts.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index d78ac929b..1ea7a9d4a 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -2,11 +2,13 @@ import type { Prisma } from "@prisma/client"; import { json } from "@remix-run/node"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { DateTime } from "luxon"; +import colors from "tailwindcss/colors"; import { z } from "zod"; import ActionsDropdown from "~/components/asset-reminder/actions-dropdown"; import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog"; import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; +import { Badge } from "~/components/shared/badge"; import { Tooltip, TooltipContent, @@ -210,7 +212,15 @@ function ListContent({ {item.name} {item.message} {item.displayDate} - {status} + + + {status} + + {item.teamMembers.map((teamMember) => ( From f7ed78259ed82a2439b8e5629bbaf4155ff9c9ae Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 7 Jan 2025 12:22:29 +0100 Subject: [PATCH 26/32] feat(asset-reminders): fix redirect issue after creating reminder --- .../set-or-edit-reminder-dialog.tsx | 25 +++++++++++++++---- app/routes/_layout+/assets.$assetId.tsx | 15 ++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx b/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx index ba1d22f2a..c720e0459 100644 --- a/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx +++ b/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import { Form, useActionData, useNavigation } from "@remix-run/react"; -import { Link } from "react-router-dom"; +import { Form, useNavigation } from "@remix-run/react"; +import { Link, useLocation, useSearchParams } from "react-router-dom"; import { useZorm } from "react-zorm"; import { z } from "zod"; import Input from "~/components/forms/input"; @@ -18,6 +18,7 @@ export const setReminderSchema = z.object({ teamMembers: z .array(z.string()) .min(1, "Please select at least one team member"), + redirectTo: z.string().optional(), }); type SetOrEditReminderDialogProps = { @@ -33,7 +34,15 @@ export default function SetOrEditReminderDialog({ }: SetOrEditReminderDialogProps) { const navigation = useNavigation(); const disabled = isFormProcessing(navigation.state); - const actionData = useActionData<{ success: boolean }>(); + + const pathname = useLocation().pathname; + const [searchParams, setSearchParams] = useSearchParams(); + + const redirectTo = `${pathname}${ + searchParams.size > 0 + ? `?${searchParams.toString()}&success=true` + : "?success=true" + }`; const zo = useZorm("SetOrEditReminder", setReminderSchema); @@ -41,11 +50,16 @@ export default function SetOrEditReminderDialog({ useEffect( function handleOnSuccess() { - if (actionData?.success) { + if (searchParams.get("success") === "true") { onClose && onClose(); + + setSearchParams((prev) => { + prev.delete("success"); + return prev; + }); } }, - [actionData, onClose] + [onClose, searchParams, setSearchParams] ); return ( @@ -76,6 +90,7 @@ export default function SetOrEditReminderDialog({ name="intent" value={isEdit ? "edit-reminder" : "set-reminder"} /> + {isEdit ? ( ) : ( diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index f8216ce32..ee351e628 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -34,7 +34,13 @@ import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; import { getDateTimeFormat, getHints } from "~/utils/client-hints"; import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError } from "~/utils/error"; -import { error, getParams, data, parseData } from "~/utils/http.server"; +import { + error, + getParams, + data, + parseData, + safeRedirect, +} from "~/utils/http.server"; import { PermissionAction, PermissionEntity, @@ -179,7 +185,10 @@ export async function action({ context, request, params }: ActionFunctionArgs) { } case "set-reminder": { - const payload = parseData(formData, setReminderSchema); + const { redirectTo, ...payload } = parseData( + formData, + setReminderSchema + ); const hints = getHints(request); const fmt = "yyyy-MM-dd'T'HH:mm"; @@ -207,7 +216,7 @@ export async function action({ context, request, params }: ActionFunctionArgs) { senderId: authSession.userId, }); - return json(data({ success: true })); + return redirect(safeRedirect(redirectTo)); } default: { From 870b7d7b7382bfbc8fa772991ecce4a7a79a9055 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 7 Jan 2025 12:41:03 +0100 Subject: [PATCH 27/32] feat(asset-reminder): fix minor issues --- app/components/assets/actions-dropdown.tsx | 3 ++- app/components/assets/asset-alert-cards.tsx | 13 +++++-------- app/components/icons/library.tsx | 19 ------------------- app/components/shared/icons-map.tsx | 5 +---- .../_layout+/assets.$assetId.overview.tsx | 2 +- 5 files changed, 9 insertions(+), 33 deletions(-) diff --git a/app/components/assets/actions-dropdown.tsx b/app/components/assets/actions-dropdown.tsx index 76b2658d6..301a15968 100644 --- a/app/components/assets/actions-dropdown.tsx +++ b/app/components/assets/actions-dropdown.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useLoaderData } from "@remix-run/react"; +import { AlarmClockIcon } from "lucide-react"; import { useHydrated } from "remix-utils/use-hydrated"; import { ChevronRight } from "~/components/icons/library"; import { @@ -224,7 +225,7 @@ const ConditionalActionsDropdown = () => { }} > - + Set reminder diff --git a/app/components/assets/asset-alert-cards.tsx b/app/components/assets/asset-alert-cards.tsx index 29cdfbf9b..9a70ddd89 100644 --- a/app/components/assets/asset-alert-cards.tsx +++ b/app/components/assets/asset-alert-cards.tsx @@ -1,4 +1,4 @@ -import { useLoaderData } from "@remix-run/react"; +import { Link, useLoaderData } from "@remix-run/react"; import { EllipsisIcon } from "lucide-react"; import { type loader } from "~/routes/_layout+/assets.$assetId.overview"; import { tw } from "~/utils/tw"; @@ -17,11 +17,8 @@ type AssetAlertCardsProps = { style?: React.CSSProperties; }; -export default function AssetAlertCards({ - className, - style, -}: AssetAlertCardsProps) { - const { alerts } = useLoaderData(); +export function AssetAlertCards({ className, style }: AssetAlertCardsProps) { + const { asset, alerts } = useLoaderData(); if (!alerts.length) { return; @@ -32,8 +29,8 @@ export default function AssetAlertCards({
Reminders
-
diff --git a/app/components/icons/library.tsx b/app/components/icons/library.tsx index f48fbf237..4e9d80fba 100644 --- a/app/components/icons/library.tsx +++ b/app/components/icons/library.tsx @@ -1821,22 +1821,3 @@ export const AlertIcon = (props: SVGProps) => ( /> ); - -export const AlarmClockIcon = (props: SVGProps) => ( - - - -); diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 4561c11ee..616320478 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -57,7 +57,6 @@ import { AvailableIcon, UnavailableIcon, ChangeIcon, - AlarmClockIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -121,8 +120,7 @@ export type IconType = | "available" | "unavailable" | "change" - | "booking-exist" - | "alarm-clock"; + | "booking-exist"; type IconsMap = { [key in IconType]: JSX.Element; @@ -189,7 +187,6 @@ export const iconsMap: IconsMap = { unavailable: , change: , "booking-exist": , - "alarm-clock": , }; export default iconsMap; diff --git a/app/routes/_layout+/assets.$assetId.overview.tsx b/app/routes/_layout+/assets.$assetId.overview.tsx index d03b37257..266f07cf3 100644 --- a/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/app/routes/_layout+/assets.$assetId.overview.tsx @@ -9,7 +9,7 @@ import { json } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import { useZorm } from "react-zorm"; import { z } from "zod"; -import AssetAlertCards from "~/components/assets/asset-alert-cards"; +import { AssetAlertCards } from "~/components/assets/asset-alert-cards"; import { CustodyCard } from "~/components/assets/asset-custody-card"; import { Switch } from "~/components/forms/switch"; import Icon from "~/components/icons/icon"; From 3df7d24e2c8c4d7e6d525acac79e47552a140e33 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Tue, 7 Jan 2025 14:15:05 +0100 Subject: [PATCH 28/32] feat(asset-reminders): fix email template and minor issue fixes --- app/components/assets/asset-alert-cards.tsx | 3 +-- app/modules/asset-reminder/emails.tsx | 13 +++++++++---- app/routes/_layout+/assets.$assetId.alerts.tsx | 12 +++++++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/app/components/assets/asset-alert-cards.tsx b/app/components/assets/asset-alert-cards.tsx index 9a70ddd89..d00301dab 100644 --- a/app/components/assets/asset-alert-cards.tsx +++ b/app/components/assets/asset-alert-cards.tsx @@ -1,5 +1,4 @@ -import { Link, useLoaderData } from "@remix-run/react"; -import { EllipsisIcon } from "lucide-react"; +import { useLoaderData } from "@remix-run/react"; import { type loader } from "~/routes/_layout+/assets.$assetId.overview"; import { tw } from "~/utils/tw"; import { resolveTeamMemberName } from "~/utils/user"; diff --git a/app/modules/asset-reminder/emails.tsx b/app/modules/asset-reminder/emails.tsx index 50861bfe5..81a3add42 100644 --- a/app/modules/asset-reminder/emails.tsx +++ b/app/modules/asset-reminder/emails.tsx @@ -79,7 +79,11 @@ function AssetAlertEmailTemplate({ @@ -148,7 +152,6 @@ function AssetAlertEmailTemplate({ ...styles.button, textAlign: "center", marginBottom: "30px", - width: "100%", }} > Open asset page @@ -158,8 +161,10 @@ function AssetAlertEmailTemplate({ This email was sent to{" "} {user.email} because it is part of the Shelf workspace{" "} - {workspaceName}. If you - think you weren't supposed to have received this email please + {workspaceName}. + + + If you think you weren't supposed to have received this email please contact the owner of the workspace. diff --git a/app/routes/_layout+/assets.$assetId.alerts.tsx b/app/routes/_layout+/assets.$assetId.alerts.tsx index 1ea7a9d4a..75ad062c6 100644 --- a/app/routes/_layout+/assets.$assetId.alerts.tsx +++ b/app/routes/_layout+/assets.$assetId.alerts.tsx @@ -27,7 +27,13 @@ import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; import { getDateTimeFormat, getHints } from "~/utils/client-hints"; import { sendNotification } from "~/utils/emitter/send-notification.server"; import { makeShelfError } from "~/utils/error"; -import { data, error, getParams, parseData } from "~/utils/http.server"; +import { + data, + error, + getParams, + parseData, + safeRedirect, +} from "~/utils/http.server"; import { PermissionAction, PermissionEntity, @@ -120,7 +126,7 @@ export async function action({ context, request }: ActionFunctionArgs) { switch (intent) { case "edit-reminder": { - const payload = parseData( + const { redirectTo, ...payload } = parseData( formData, setReminderSchema.extend({ id: z.string() }) ); @@ -150,7 +156,7 @@ export async function action({ context, request }: ActionFunctionArgs) { senderId: authSession.userId, }); - return json(data({ success: true })); + return json(safeRedirect(redirectTo)); } case "delete-reminder": { From 9df6ca93e36775d8559cce4186b231d865ffb2f1 Mon Sep 17 00:00:00 2001 From: Donkoko Date: Tue, 7 Jan 2025 16:18:01 +0200 Subject: [PATCH 29/32] small style adjustments + changing order of reminders on list --- app/components/assets/asset-alert-cards.tsx | 6 +++++- app/modules/asset-reminder/service.server.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/components/assets/asset-alert-cards.tsx b/app/components/assets/asset-alert-cards.tsx index d00301dab..6aca1013c 100644 --- a/app/components/assets/asset-alert-cards.tsx +++ b/app/components/assets/asset-alert-cards.tsx @@ -28,7 +28,11 @@ export function AssetAlertCards({ className, style }: AssetAlertCardsProps) {
Reminders
-
diff --git a/app/modules/asset-reminder/service.server.ts b/app/modules/asset-reminder/service.server.ts index 8eb3ed758..5ec72d43e 100644 --- a/app/modules/asset-reminder/service.server.ts +++ b/app/modules/asset-reminder/service.server.ts @@ -106,6 +106,7 @@ export async function getPaginatedAndFilterableReminders({ take, skip, include: ASSET_REMINDER_INCLUDE_FIELDS, + orderBy: { alertDateTime: "desc" }, }), db.assetReminder.count({ where: { assetId, organizationId }, @@ -235,11 +236,12 @@ export async function getRemindersForOverviewPage({ organizationId: AssetReminder["organizationId"]; }) { try { - return await db.assetReminder.findMany({ + const reminders = await db.assetReminder.findMany({ where: { assetId, organizationId }, take: 2, include: ASSET_REMINDER_INCLUDE_FIELDS, }); + return reminders; } catch (cause) { throw new ShelfError({ cause, From cfb02ef81f2bbe46b7f20ab41527206838b2c24f Mon Sep 17 00:00:00 2001 From: Donkoko Date: Tue, 7 Jan 2025 17:37:56 +0200 Subject: [PATCH 30/32] importing scheduler inside asset-reminder workers file --- app/modules/asset-reminder/worker.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/modules/asset-reminder/worker.server.ts b/app/modules/asset-reminder/worker.server.ts index b80ee1c9c..03c867491 100644 --- a/app/modules/asset-reminder/worker.server.ts +++ b/app/modules/asset-reminder/worker.server.ts @@ -5,6 +5,7 @@ import { db } from "~/database/db.server"; import { sendEmail } from "~/emails/mail.server"; import { ShelfError } from "~/utils/error"; import { Logger } from "~/utils/logger"; +import { scheduler } from "~/utils/scheduler.server"; import { assetAlertEmailHtmlString, assetAlertEmailText } from "./emails"; import { ASSETS_QUEUE_KEY } from "./scheduler.server"; import type { AssetsEventType, AssetsSchedulerData } from "./scheduler.server"; From 54814e1ebe3d6f169d2869829a6814cb89a3d467 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Wed, 8 Jan 2025 09:41:22 +0100 Subject: [PATCH 31/32] feat(asset-reminders): centered logo in email template --- app/modules/asset-reminder/emails.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/app/modules/asset-reminder/emails.tsx b/app/modules/asset-reminder/emails.tsx index 81a3add42..32290dd78 100644 --- a/app/modules/asset-reminder/emails.tsx +++ b/app/modules/asset-reminder/emails.tsx @@ -85,7 +85,15 @@ function AssetAlertEmailTemplate({ textAlign: "center", }} > - +
+ +
Asset Reminder Notice @@ -168,7 +176,15 @@ function AssetAlertEmailTemplate({ contact the owner of the workspace. - +
+ +
From b463fb5fc3f133589ed18a9d513adf5b6d6afbc4 Mon Sep 17 00:00:00 2001 From: Rohit Kumar Saini Date: Wed, 8 Jan 2025 12:01:29 +0100 Subject: [PATCH 32/32] feat(qr-column): create qrId column with qr code preview --- .../assets-index/advanced-asset-columns.tsx | 5 +- .../assets/assets-index/qr-id-column.tsx | 100 ++++++++++++++++++ app/components/qr/qr-preview.tsx | 59 ++++++----- .../api+/assets.$assetId.generate-qr-obj.ts | 39 +++++++ 4 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 app/components/assets/assets-index/qr-id-column.tsx create mode 100644 app/routes/api+/assets.$assetId.generate-qr-obj.ts diff --git a/app/components/assets/assets-index/advanced-asset-columns.tsx b/app/components/assets/assets-index/advanced-asset-columns.tsx index b4b820ab4..11b2d5e33 100644 --- a/app/components/assets/assets-index/advanced-asset-columns.tsx +++ b/app/components/assets/assets-index/advanced-asset-columns.tsx @@ -51,6 +51,7 @@ import { resolveTeamMemberName } from "~/utils/user"; import { freezeColumnClassNames } from "./freeze-column-classes"; import { AssetImage } from "../asset-image"; import { AssetStatusBadge } from "../asset-status-badge"; +import QrIdColumn from "./qr-id-column"; export function AdvancedIndexColumn({ column, @@ -158,9 +159,11 @@ export function AdvancedIndexColumn({ ); case "id": - case "qrId": return ; + case "qrId": + return ; + case "status": return ; diff --git a/app/components/assets/assets-index/qr-id-column.tsx b/app/components/assets/assets-index/qr-id-column.tsx new file mode 100644 index 000000000..3c6f0812a --- /dev/null +++ b/app/components/assets/assets-index/qr-id-column.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { Dialog, DialogPortal } from "~/components/layout/dialog"; +import { QrPreview } from "~/components/qr/qr-preview"; +import { Button } from "~/components/shared/button"; +import { Td } from "~/components/table"; +import When from "~/components/when/when"; +import useApiQuery from "~/hooks/use-api-query"; +import type { AdvancedIndexAsset } from "~/modules/asset/types"; +import { tw } from "~/utils/tw"; + +type QrIdColumnProps = { + className?: string; + style?: React.CSSProperties; + asset: AdvancedIndexAsset; +}; + +export default function QrIdColumn({ + className, + style, + asset, +}: QrIdColumnProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { isLoading, data, error } = useApiQuery<{ + qrObj: React.ComponentProps["qrObj"]; + }>({ + api: `/api/assets/${asset.id}/generate-qr-obj`, + enabled: isDialogOpen, + }); + + function openDialog() { + setIsDialogOpen(true); + } + + function closeDialog() { + setIsDialogOpen(false); + } + + return ( + <> + + + + + + +
+
+ +
+
+

Fetching qr code...

+
+
+
+ +

{error}

+
+ + + +
+
+ +
+
+
+
+ + ); +} diff --git a/app/components/qr/qr-preview.tsx b/app/components/qr/qr-preview.tsx index 466c9e699..4961288af 100644 --- a/app/components/qr/qr-preview.tsx +++ b/app/components/qr/qr-preview.tsx @@ -4,9 +4,11 @@ import domtoimage from "dom-to-image"; import { useReactToPrint } from "react-to-print"; import { Button } from "~/components/shared/button"; import { slugify } from "~/utils/slugify"; +import { tw } from "~/utils/tw"; type SizeKeys = "cable" | "small" | "medium" | "large"; interface ObjectType { + className?: string; item: { name: string; type: "asset" | "kit"; @@ -20,7 +22,7 @@ interface ObjectType { }; } -export const QrPreview = ({ qrObj, item }: ObjectType) => { +export const QrPreview = ({ className, qrObj, item }: ObjectType) => { const captureDivRef = useRef(null); const downloadQrBtnRef = useRef(null); @@ -67,32 +69,35 @@ export const QrPreview = ({ qrObj, item }: ObjectType) => { content: () => captureDivRef.current, }); return ( -
-
-
- -
-
- - -
+
+
+ +
+
+ +
); diff --git a/app/routes/api+/assets.$assetId.generate-qr-obj.ts b/app/routes/api+/assets.$assetId.generate-qr-obj.ts new file mode 100644 index 000000000..9071aee65 --- /dev/null +++ b/app/routes/api+/assets.$assetId.generate-qr-obj.ts @@ -0,0 +1,39 @@ +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { generateQrObj } from "~/modules/qr/utils.server"; +import { makeShelfError } from "~/utils/error"; +import { data, error, getParams } from "~/utils/http.server"; +import { + PermissionAction, + PermissionEntity, +} from "~/utils/permissions/permission.data"; +import { requirePermission } from "~/utils/roles.server"; + +export async function loader({ context, params, request }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + const { assetId } = getParams(params, z.object({ assetId: z.string() }), { + additionalData: { userId, ...params }, + }); + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.qr, + action: PermissionAction.read, + }); + + const qrObj = await generateQrObj({ + assetId, + userId, + organizationId, + }); + + return json(data({ qrObj })); + } catch (cause) { + const reason = makeShelfError(cause, { userId, assetId }); + return json(error(reason), { status: reason.status }); + } +}