diff --git a/app/components/asset-reminder/actions-dropdown.tsx b/app/components/asset-reminder/actions-dropdown.tsx new file mode 100644 index 000000000..99709ad59 --- /dev/null +++ b/app/components/asset-reminder/actions-dropdown.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import type { Prisma } from "@prisma/client"; +import { PencilIcon } from "lucide-react"; +import { VerticalDotsIcon } from "~/components/icons/library"; +import { Button } from "~/components/shared/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/shared/dropdown"; +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<{ + include: typeof ASSET_REMINDER_INCLUDE_FIELDS; + }>; +}; + +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 ( + + + + + + + + + + + + + + + + + tm.id), + }} + open={isEditDialogOpen} + onClose={() => { + setIsEditDialogOpen(false); + }} + /> + + ); +} diff --git a/app/components/asset-reminder/delete-reminder.tsx b/app/components/asset-reminder/delete-reminder.tsx new file mode 100644 index 000000000..367ce10d8 --- /dev/null +++ b/app/components/asset-reminder/delete-reminder.tsx @@ -0,0 +1,78 @@ +import { forwardRef } from "react"; +import type { Prisma } from "@prisma/client"; +import { Form, useNavigation } from "@remix-run/react"; +import { TrashIcon } from "lucide-react"; +import { Button } from "~/components/shared/button"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/shared/modal"; +import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; +import { isFormProcessing } from "~/utils/form"; + +type DeleteReminderProps = { + reminder: Prisma.AssetReminderGetPayload<{ + include: typeof ASSET_REMINDER_INCLUDE_FIELDS; + }>; +}; + +const DeleteReminder = forwardRef( + function ({ reminder }, ref) { + 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. + +
+ +
+ + + + +
+ + + + +
+
+
+
+
+ ); + } +); + +DeleteReminder.displayName = "DeleteReminder"; +export default DeleteReminder; diff --git a/app/components/asset-reminder/reminder-team-members.tsx b/app/components/asset-reminder/reminder-team-members.tsx new file mode 100644 index 000000000..9237de84d --- /dev/null +++ b/app/components/asset-reminder/reminder-team-members.tsx @@ -0,0 +1,90 @@ +import type { Prisma } from "@prisma/client"; +import { Link } from "@remix-run/react"; +import { tw } from "~/utils/tw"; +import { resolveTeamMemberName } from "~/utils/user"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../shared/tooltip"; +import When from "../when/when"; + +type ReminderTeamMembersProps = { + className?: string; + style?: React.CSSProperties; + teamMembers: Prisma.TeamMemberGetPayload<{ + select: { + id: true; + name: true; + user: { + select: { + id: true; + firstName: true; + lastName: true; + profilePicture: true; + }; + }; + }; + }>[]; + imgClassName?: string; + extraContent?: React.ReactNode; + isAlreadySent?: boolean; +}; + +export default function ReminderTeamMembers({ + className, + style, + teamMembers, + imgClassName, + extraContent, + isAlreadySent = false, +}: ReminderTeamMembersProps) { + return ( +
+ {teamMembers.map((teamMember) => { + const isAccessRevoed = !teamMember.user; + + return ( + + + + + {teamMember.name} + + + +

{resolveTeamMemberName(teamMember, true)}

+ + +

+ This team member has been removed from the workspace. As a + fallback the reminder email will be sent to the workspace + Owner. You can always edit the reminder to assign it to a + different user. +

+
+
+
+
+ ); + })} + + {extraContent} +
+ ); +} diff --git a/app/components/asset-reminder/reminders-table.tsx b/app/components/asset-reminder/reminders-table.tsx new file mode 100644 index 000000000..b413d8ace --- /dev/null +++ b/app/components/asset-reminder/reminders-table.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import type { Prisma } from "@prisma/client"; +import colors from "tailwindcss/colors"; +import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; +import { List } from "../list"; +import ReminderTeamMembers from "./reminder-team-members"; +import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog"; +import { ListContentWrapper } from "../list/content-wrapper"; +import { Filters } from "../list/filters"; +import { SortBy } from "../list/filters/sort-by"; +import { Badge } from "../shared/badge"; +import { Button } from "../shared/button"; +import { Td, Th } from "../table"; +import ActionsDropdown from "./actions-dropdown"; +import When from "../when/when"; + +type RemindersTableProps = { + isAssetReminderPage?: boolean; +}; + +export const REMINDERS_SORTING_OPTIONS = { + name: "Name", + alertDateTime: "Alert Time", + createdAt: "Date Created", + updatedAt: "Date Updated", +} as const; + +export default function RemindersTable({ + isAssetReminderPage, +}: RemindersTableProps) { + const [isReminderDialogOpen, setIsReminderDialogOpen] = useState(false); + + const emptyStateTitle = isAssetReminderPage + ? "No reminders for this asset" + : "No reminders created yet."; + + return ( + + + ), + }} + /> + + + What are you waiting for? Create your first{" "} + {isAssetReminderPage ? ( + + ) : ( + "reminder" + )}{" "} + now! +

+ ), + }} + headerChildren={ + <> + Message + + Asset + + Alert Date + Status + Users + + } + extraItemComponentProps={{ isAssetReminderPage }} + /> + + { + setIsReminderDialogOpen(false); + }} + /> +
+ ); +} + +function ListContent({ + item, + extraProps, +}: { + item: Prisma.AssetReminderGetPayload<{ + include: typeof ASSET_REMINDER_INCLUDE_FIELDS; + }> & { displayDate: string }; + extraProps: { isAssetReminderPage: boolean }; +}) { + const now = new Date(); + const status = + now < new Date(item.alertDateTime) ? "Pending" : "Reminder sent"; + + return ( + <> + {item.name} + {item.message} + + + + + + {item.displayDate} + + + {status} + + + + + + + + + + ); +} diff --git a/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx b/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx new file mode 100644 index 000000000..f3d487f91 --- /dev/null +++ b/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { Form, useNavigation } from "@remix-run/react"; +import { useLocation, useSearchParams } 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"), + redirectTo: z.string().optional(), +}); + +type SetOrEditReminderDialogProps = { + open: boolean; + onClose: () => void; + reminder?: z.infer & { id: string }; +}; + +export default function SetOrEditReminderDialog({ + open, + onClose, + reminder, +}: SetOrEditReminderDialogProps) { + const navigation = useNavigation(); + const disabled = isFormProcessing(navigation.state); + + 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); + + const isEdit = !!reminder; + + useEffect( + function handleOnSuccess() { + if (searchParams.get("success") === "true") { + onClose && onClose(); + + setSearchParams((prev) => { + prev.delete("success"); + return prev; + }); + } + }, + [onClose, searchParams, setSearchParams] + ); + + return ( + + +

Set Reminder

+

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

+ + } + > +
+
+ + + {isEdit ? ( + + ) : ( + false + )} + + + +
+ +

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

+
+ +
+ +

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

+
+
+
+ +

Select team member(s)

+ +
+
+ + +
+
+
+
+ ); +} diff --git a/app/components/asset-reminder/team-members-selector.tsx b/app/components/asset-reminder/team-members-selector.tsx new file mode 100644 index 000000000..58b2a5945 --- /dev/null +++ b/app/components/asset-reminder/team-members-selector.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState } from "react"; +import { CheckIcon, UserIcon } from "lucide-react"; +import { Separator } from "~/components/shared/separator"; +import When from "~/components/when/when"; +import useApiQuery from "~/hooks/use-api-query"; +import type { ReminderTeamMember } from "~/routes/api+/reminders.team-members"; +import { tw } from "~/utils/tw"; +import { resolveTeamMemberName } from "~/utils/user"; + +type TeamMembersSelectorProps = { + className?: string; + style?: React.CSSProperties; + error?: string; + defaultValues?: string[]; +}; + +export default function TeamMembersSelector({ + className, + style, + error, + defaultValues, +}: TeamMembersSelectorProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedTeamMembers, setSelectedTeamMembers] = useState( + defaultValues?.length ? defaultValues : [] + ); + + const { isLoading, data } = useApiQuery<{ + teamMembers: ReminderTeamMember[]; + }>({ + api: "/api/reminders/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) || + tm.user?.email?.includes(normalizedQuery) + ); + }, [data, searchQuery]); + + return ( +
+
+ + { + setSearchQuery(event.target.value); + }} + /> +
+ +

{error}

+
+ + + + {selectedTeamMembers.map((item, i) => ( + + ))} + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} + + + + {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 +

+ {resolveTeamMemberName(teamMember, true)} +

+
+ + + + +
+ ); + })} +
+
+ ); +} diff --git a/app/components/assets/actions-dropdown.tsx b/app/components/assets/actions-dropdown.tsx index 5e2611b0d..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 { @@ -21,6 +22,7 @@ import { tw } from "~/utils/tw"; import { DeleteAsset } from "./delete-asset"; import RelinkQrCodeDialog from "./relink-qr-code-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"; @@ -28,11 +30,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 +174,14 @@ const ConditionalActionsDropdown = () => { })} > + + + + + +
+ + {reminders.map((reminder) => { + const slicedTeamMembers = reminder.teamMembers.slice(0, 10); + const remainingTeamMembers = + reminder.teamMembers.length - slicedTeamMembers.length; + const isAlreadySent = new Date() > new Date(reminder.alertDateTime); + + return ( +
+ +

{reminder.displayDate}

+ +

+ {reminder.message.substring(0, 1000)} +

+ + 0}> +
+ +{remainingTeamMembers} +
+ + } + /> +
+ ); + })} + + ); +} diff --git a/app/components/assets/assets-index/advanced-asset-columns.tsx b/app/components/assets/assets-index/advanced-asset-columns.tsx index dbe21ae04..1a2f710cd 100644 --- a/app/components/assets/assets-index/advanced-asset-columns.tsx +++ b/app/components/assets/assets-index/advanced-asset-columns.tsx @@ -240,6 +240,14 @@ export function AdvancedIndexColumn({ case "availableToBook": return ; + + case "upcomingReminder": + return ( + + ); } } @@ -402,3 +410,32 @@ function CustodyColumn({ function Td({ className, ...rest }: React.ComponentProps) { return ; } + +function UpcomingReminderColumn({ + assetId, + upcomingReminder, +}: { + assetId: string; + upcomingReminder: AdvancedIndexAsset["upcomingReminder"]; +}) { + if (!upcomingReminder) { + return No upcoming reminder; + } + + return ( + + + + + + + +

{upcomingReminder.name}

+

{upcomingReminder.message.substring(0, 1000)}

+
+
+ + ); +} diff --git a/app/components/assets/assets-index/filters.tsx b/app/components/assets/assets-index/filters.tsx index 116e515ce..a70eed576 100644 --- a/app/components/assets/assets-index/filters.tsx +++ b/app/components/assets/assets-index/filters.tsx @@ -23,6 +23,12 @@ import { resolveTeamMemberName } from "~/utils/user"; import { AdvancedFilteringAndSorting } from "./advanced-asset-index-filters-and-sorting"; import { ConfigureColumnsDropdown } from "./configure-columns-dropdown"; +const ASSET_INDEX_SORTING_OPTIONS = { + title: "Name", + createdAt: "Date created", + updatedAt: "Date updated", +} as const; + export function AssetIndexFilters({ disableTeamMemberFilter, }: { @@ -44,7 +50,12 @@ export function AssetIndexFilters({ , - "right-of-search": , + "right-of-search": ( + + ), }} >
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}
-

{customContent.text}

+
{customContent.text}
) : (
diff --git a/app/components/list/filters/sort-by.tsx b/app/components/list/filters/sort-by.tsx index 86c76ed98..b4db04d78 100644 --- a/app/components/list/filters/sort-by.tsx +++ b/app/components/list/filters/sort-by.tsx @@ -9,21 +9,26 @@ import { useNavigation } from "@remix-run/react"; import { useSearchParams } from "~/hooks/search-params"; import { isFormProcessing } from "~/utils/form"; -import { tw } from "~/utils/tw"; -const sortingOptions: { [key: string]: string } = { - title: "Name", - createdAt: "Date created", - updatedAt: "Date updated", -}; - -export type SortingOptions = keyof typeof sortingOptions; +type TSort = Record; +export type SortingOptions = keyof TSort; export type SortingDirection = "asc" | "desc"; -export function SortBy() { +type SortByProps = { + sortingOptions: T; + defaultSortingBy: keyof T; + defaultSortingDirection?: SortingDirection; +}; + +export function SortBy>({ + sortingOptions, + defaultSortingBy, + defaultSortingDirection = "desc", +}: SortByProps) { const [searchParams, setSearchParams] = useSearchParams(); - const orderBy = searchParams.get("orderBy") || "createdAt"; - const orderDirection = searchParams.get("orderDirection") || "desc"; + const orderBy = searchParams.get("orderBy") || String(defaultSortingBy); + const orderDirection = + searchParams.get("orderDirection") || defaultSortingDirection; const navigation = useNavigation(); const disabled = isFormProcessing(navigation.state); @@ -52,9 +57,7 @@ export function SortBy() {
Sort by:
diff --git a/app/components/list/index.tsx b/app/components/list/index.tsx index d80a630fa..e5b9effcd 100644 --- a/app/components/list/index.tsx +++ b/app/components/list/index.tsx @@ -65,7 +65,7 @@ export type ListProps = { className?: string; customEmptyStateContent?: { title: string; - text: string; + text: React.ReactNode; newButtonRoute?: string; newButtonContent?: string; buttonProps?: any; @@ -80,6 +80,8 @@ export type ListProps = { customPagination?: React.ReactElement; /** Any extra content to the right in Header */ headerExtraContent?: React.ReactNode; + /** Any extra props directly passed to ItemComponent */ + extraItemComponentProps?: Record; }; /** @@ -98,6 +100,7 @@ export const List = React.forwardRef(function List( bulkActions, customPagination, headerExtraContent, + extraItemComponentProps, }: ListProps, ref ) { @@ -233,7 +236,10 @@ export const List = React.forwardRef(function List( navigate={navigate} > {bulkActions ? : null} - + ))} diff --git a/app/components/shared/separator.tsx b/app/components/shared/separator.tsx index 218b281c1..510914393 100644 --- a/app/components/shared/separator.tsx +++ b/app/components/shared/separator.tsx @@ -15,7 +15,7 @@ const Separator = forwardRef< decorative={decorative} orientation={orientation} className={tw( - "bg-border shrink-0", + "shrink-0 bg-gray-100", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className )} 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 new file mode 100644 index 000000000..06a0362bf --- /dev/null +++ b/app/database/migrations/20241217150402_create_schema_for_asset_reminders/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "AssetReminder" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "message" TEXT NOT NULL, + "alertDateTime" TIMESTAMP(3) NOT NULL, + "assetId" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AssetReminder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_AssetReminderToTeamMember" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_AssetReminderToTeamMember_AB_unique" ON "_AssetReminderToTeamMember"("A", "B"); + +-- CreateIndex +CREATE INDEX "_AssetReminderToTeamMember_B_index" ON "_AssetReminderToTeamMember"("B"); + +-- AddForeignKey +ALTER TABLE "AssetReminder" ADD CONSTRAINT "AssetReminder_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssetReminder" ADD CONSTRAINT "AssetReminder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_AssetReminderToTeamMember" ADD CONSTRAINT "_AssetReminderToTeamMember_A_fkey" FOREIGN KEY ("A") REFERENCES "AssetReminder"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_AssetReminderToTeamMember" ADD CONSTRAINT "_AssetReminderToTeamMember_B_fkey" FOREIGN KEY ("B") REFERENCES "TeamMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Enable Row level security +ALTER TABLE "AssetReminder" ENABLE row level security; \ No newline at end of file diff --git a/app/database/migrations/20241217152549_add_organization_in_asset_reminder/migration.sql b/app/database/migrations/20241217152549_add_organization_in_asset_reminder/migration.sql new file mode 100644 index 000000000..1fe5c5d7b --- /dev/null +++ b/app/database/migrations/20241217152549_add_organization_in_asset_reminder/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `organizationId` to the `AssetReminder` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "AssetReminder" ADD COLUMN "organizationId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "AssetReminder" ADD CONSTRAINT "AssetReminder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 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/migrations/20250114124237_add_reminder_indexes_for_optimization/migration.sql b/app/database/migrations/20250114124237_add_reminder_indexes_for_optimization/migration.sql new file mode 100644 index 000000000..8760fb328 --- /dev/null +++ b/app/database/migrations/20250114124237_add_reminder_indexes_for_optimization/migration.sql @@ -0,0 +1,20 @@ +-- CreateIndex +CREATE INDEX "Asset_title_description_idx" ON "Asset" USING GIN ("title" gin_trgm_ops, "description" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "AssetReminder_assetId_alertDateTime_idx" ON "AssetReminder"("assetId", "alertDateTime"); + +-- CreateIndex +CREATE INDEX "AssetReminder_name_message_idx" ON "AssetReminder" USING GIN ("name" gin_trgm_ops, "message" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "AssetReminder_organizationId_alertDateTime_assetId_idx" ON "AssetReminder"("organizationId", "alertDateTime", "assetId"); + +-- CreateIndex +CREATE INDEX "AssetReminder_alertDateTime_activeSchedulerReference_idx" ON "AssetReminder"("alertDateTime", "activeSchedulerReference"); + +-- CreateIndex +CREATE INDEX "TeamMember_name_idx" ON "TeamMember" USING GIN ("name" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX "User_firstName_lastName_idx" ON "User"("firstName", "lastName"); diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 996f378ec..1e6450462 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -25,7 +25,7 @@ model Image { ownerOrg Organization @relation("owner", fields: [ownerOrgId], references: [id], onDelete: Cascade, onUpdate: Cascade) ownerOrgId String - user User @relation(fields: [userId], references: [id], onUpdate: Cascade) + user User @relation(fields: [userId], references: [id], onUpdate: Cascade) userId String } @@ -71,14 +71,16 @@ model User { createdKits Kit[] tierId TierId @default(free) tier Tier @relation(fields: [tierId], references: [id]) + assetReminders AssetReminder[] // A user can have multiple asset index settings, 1 for each organization - assetIndexSettings AssetIndexSettings[] + assetIndexSettings AssetIndexSettings[] // This relationship will be used only when tierId == custom customTierLimit CustomTierLimit? @@unique([email, username]) + @@index([firstName, lastName]) } model Asset { @@ -107,18 +109,17 @@ 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[] + custody Custody? + notes Note[] + qrCodes Qr[] + reports ReportFound[] + tags Tag[] + customFields AssetCustomFieldValue[] + 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") @@ -129,14 +130,12 @@ model Asset { @@index([kitId, organizationId], name: "Asset_kitId_organizationId_idx") } - enum AssetStatus { AVAILABLE IN_CUSTODY CHECKED_OUT } - model AssetIndexSettings { id String @id @default(cuid()) @@ -156,10 +155,9 @@ model AssetIndexSettings { // Format is {name: string, visible: boolean, position: number}[] where name is the name of the column, visible is whether the column is visible or not and position is the position of the column // The shape of the JSON includes both some fixed entries that will always be present and some dynamic entries for the custom fields // The fixed entries are: id, status, description, valuation, createdAt, category, tags, location, kit, custody - columns Json @default("[ {\"name\": \"id\", \"visible\": true, \"position\": 0}, {\"name\": \"status\", \"visible\": true, \"position\": 1}, {\"name\": \"description\", \"visible\": true, \"position\": 2}, {\"name\": \"valuation\", \"visible\": true, \"position\": 3}, {\"name\": \"createdAt\", \"visible\": true, \"position\": 4}, {\"name\": \"category\", \"visible\": true, \"position\": 5}, {\"name\": \"tags\", \"visible\": true, \"position\": 6}, {\"name\": \"location\", \"visible\": true, \"position\": 7}, {\"name\": \"kit\", \"visible\": true, \"position\": 8}, {\"name\": \"custody\", \"visible\": true, \"position\": 9} ]") - + columns Json @default("[ {\"name\": \"id\", \"visible\": true, \"position\": 0}, {\"name\": \"status\", \"visible\": true, \"position\": 1}, {\"name\": \"description\", \"visible\": true, \"position\": 2}, {\"name\": \"valuation\", \"visible\": true, \"position\": 3}, {\"name\": \"createdAt\", \"visible\": true, \"position\": 4}, {\"name\": \"category\", \"visible\": true, \"position\": 5}, {\"name\": \"tags\", \"visible\": true, \"position\": 6}, {\"name\": \"location\", \"visible\": true, \"position\": 7}, {\"name\": \"kit\", \"visible\": true, \"position\": 8}, {\"name\": \"custody\", \"visible\": true, \"position\": 9} ]") - freezeColumn Boolean @default(true) + freezeColumn Boolean @default(true) showAssetImage Boolean @default(true) // Datetime @@ -167,7 +165,6 @@ model AssetIndexSettings { updatedAt DateTime @updatedAt @@unique([userId, organizationId]) - } enum AssetIndexMode { @@ -175,10 +172,6 @@ enum AssetIndexMode { ADVANCED } - - - - model Category { id String @id @default(cuid()) name String @@ -234,7 +227,7 @@ model Note { // Relationships user User? @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: SetNull) userId String? - asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: Cascade) + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: Cascade) assetId String } @@ -397,11 +390,12 @@ model TeamMember { userId String? kitCustodies KitCustody[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - bookings Booking[] - + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + bookings Booking[] + assetReminders AssetReminder[] + // Special GIN index for optimization of simple search queries @@index([name(ops: raw("gin_trgm_ops"))], type: Gin) } @@ -427,7 +421,7 @@ model Organization { name String @default("Personal") type OrganizationType @default(PERSONAL) - owner User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + owner User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) userId String currency Currency @default(USD) @@ -450,13 +444,14 @@ model Organization { invites Invite[] userOrganizations UserOrganization[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - customFields CustomField[] - images Image[] @relation("owner") - bookings Booking[] - kits Kit[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + customFields CustomField[] + images Image[] @relation("owner") + bookings Booking[] + kits Kit[] assetIndexSettings AssetIndexSettings[] + assetReminders AssetReminder[] } model UserOrganization { @@ -769,3 +764,29 @@ model KitCustody { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model AssetReminder { + id String @id @default(cuid()) + name String + message String + alertDateTime DateTime + activeSchedulerReference String? + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String + asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) + assetId String + teamMembers TeamMember[] + createdBy User @relation(fields: [createdById], references: [id]) + createdById String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Optimized indexes + @@index([assetId, alertDateTime]) + @@index([name(ops: raw("gin_trgm_ops")), message(ops: raw("gin_trgm_ops"))], type: Gin) + @@index([organizationId, alertDateTime, assetId]) + + // For scheduler related queries + @@index([alertDateTime, activeSchedulerReference]) + +} diff --git a/app/emails/logo.tsx b/app/emails/logo.tsx index 0acb1402a..532e4d770 100644 --- a/app/emails/logo.tsx +++ b/app/emails/logo.tsx @@ -19,6 +19,7 @@ export function LogoForEmail() { color: "#101828", fontWeight: "600", margin: "0", + marginLeft: "6px", }} > shelf diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 7c0dd4e86..abf571a68 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-reminder/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/hooks/use-sidebar-nav-items.tsx b/app/hooks/use-sidebar-nav-items.tsx index 0020e4301..c682f0938 100644 --- a/app/hooks/use-sidebar-nav-items.tsx +++ b/app/hooks/use-sidebar-nav-items.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useLoaderData } from "@remix-run/react"; import { Crisp } from "crisp-sdk-web"; import { + AlarmClockIcon, BoxesIcon, BriefcaseConveyorBeltIcon, CalendarRangeIcon, @@ -156,6 +157,13 @@ export function useSidebarNavItems() { }, ], }, + { + type: "child", + title: "Reminders", + Icon: AlarmClockIcon, + hidden: isBaseOrSelfService, + to: "/reminders", + }, { type: "label", title: "Organization", diff --git a/app/modules/asset-index-settings/helpers.ts b/app/modules/asset-index-settings/helpers.ts index 19d94baf2..fd403e690 100644 --- a/app/modules/asset-index-settings/helpers.ts +++ b/app/modules/asset-index-settings/helpers.ts @@ -21,6 +21,7 @@ export const fixedFields = [ "location", "kit", "custody", + "upcomingReminder", ] as const; export type FixedField = (typeof fixedFields)[number]; @@ -45,6 +46,7 @@ export const columnsLabelsMap: { [key in ColumnLabelKey]: string } = { location: "Location", kit: "Kit", custody: "Custody", + upcomingReminder: "Upcoming Reminder", }; export const defaultFields: Column[] = [ @@ -60,6 +62,7 @@ export const defaultFields: Column[] = [ { name: "location", visible: true, position: 9 }, { name: "kit", visible: true, position: 10 }, { name: "custody", visible: true, position: 11 }, + { name: "upcomingReminder", visible: true, position: 12 }, ]; export const generateColumnsSchema = (customFields: string[]) => { diff --git a/app/modules/asset-reminder/emails.tsx b/app/modules/asset-reminder/emails.tsx new file mode 100644 index 000000000..07864f82e --- /dev/null +++ b/app/modules/asset-reminder/emails.tsx @@ -0,0 +1,213 @@ +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: Pick; + asset: Pick; + reminder: AssetReminder; + workspaceName: string; + isOwner?: boolean; +}; + +export function assetAlertEmailText({ + user, + asset, + reminder, + workspaceName, + isOwner, +}: AssetAlertEmailProps) { + const userName = `${user.firstName?.trim()} ${user.lastName?.trim()}`; + + const note = isOwner + ? `You are receiving this email because the original person was removed from workspace ${workspaceName}.` + : `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.`; + + 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} + +${note} + +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, + isOwner, +}: 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} + + + + + {isOwner ? ( + + You are receiving this email because the original person was + removed from workspace{" "} + {workspaceName}. + + ) : ( + <> + + 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/modules/asset-reminder/fields.ts b/app/modules/asset-reminder/fields.ts new file mode 100644 index 000000000..a896b75fc --- /dev/null +++ b/app/modules/asset-reminder/fields.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "@prisma/client"; + +export const ASSET_REMINDER_INCLUDE_FIELDS = { + asset: { + select: { id: true, title: true }, + }, + teamMembers: { + select: { + id: true, + name: true, + user: { + select: { + firstName: true, + lastName: true, + profilePicture: true, + email: true, + id: true, + }, + }, + }, + }, +} satisfies Prisma.AssetReminderInclude; diff --git a/app/modules/asset-reminder/scheduler.server.ts b/app/modules/asset-reminder/scheduler.server.ts new file mode 100644 index 000000000..28cbf9152 --- /dev/null +++ b/app/modules/asset-reminder/scheduler.server.ts @@ -0,0 +1,83 @@ +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"; +import { scheduler } from "~/utils/scheduler.server"; + +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 }, + }); + } +} + +/** + * This function is used to cancel an asset reminder scheduler. + */ +export async function cancelAssetReminderScheduler( + reminder: Pick +) { + 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-reminder/service.server.ts b/app/modules/asset-reminder/service.server.ts new file mode 100644 index 000000000..55aac682d --- /dev/null +++ b/app/modules/asset-reminder/service.server.ts @@ -0,0 +1,330 @@ +import type { AssetReminder, Prisma, TeamMember } from "@prisma/client"; +import { db } from "~/database/db.server"; +import { getDateTimeFormat } from "~/utils/client-hints"; +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"; +import { createNote } from "../note/service.server"; +import { getUserByID } from "../user/service.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 user = await getUserByID(createdById); + + const [assetReminder] = await Promise.all([ + db.assetReminder.create({ + data: { + name, + message, + alertDateTime, + assetId, + createdById, + organizationId, + teamMembers: { + connect: teamMembers.map((id) => ({ id })), + }, + }, + }), + createNote({ + assetId, + userId: createdById, + type: "UPDATE", + content: `**${user.firstName?.trim()} ${user.lastName?.trim()}** has created a new reminder **${name.trim()}**.`, + }), + ]); + + 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({ + organizationId, + request, + where, +}: Pick & { + request: Request; + where?: Prisma.AssetReminderWhereInput; +}) { + try { + const searchParams = getCurrentSearchParams(request); + const { page, perPageParam, search, orderBy, orderDirection } = + 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 finalWhere: Prisma.AssetReminderWhereInput = { + organizationId, + ...where, + }; + + if (search) { + const searchTerms = search + .toLowerCase() + .trim() + .split(",") + .map((term) => term.trim()) + .filter(Boolean); + + finalWhere.OR = searchTerms.map((term) => ({ + OR: [ + { name: { contains: term, mode: "insensitive" } }, + { message: { contains: term, mode: "insensitive" } }, + { + teamMembers: { + some: { + user: { + OR: [ + { + firstName: { + contains: term, + mode: "insensitive", + }, + }, + { + lastName: { + contains: term, + mode: "insensitive", + }, + }, + ], + }, + }, + }, + }, + { + asset: { title: { contains: term, mode: "insensitive" } }, + }, + ], + })); + } + + const [reminders, totalReminders] = await Promise.all([ + db.assetReminder.findMany({ + where: finalWhere, + take, + skip, + include: ASSET_REMINDER_INCLUDE_FIELDS, + orderBy: { [orderBy ?? "alertDateTime"]: orderDirection ?? "desc" }, + }), + db.assetReminder.count({ where: finalWhere }), + ]); + + const totalPages = Math.ceil(totalReminders / perPageParam); + + return { + reminders, + totalReminders, + page, + perPage, + totalPages, + search, + }; + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while getting asset reminders.", + 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 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: { + 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, + }); + } +} + +export async function getRemindersForOverviewPage({ + assetId, + organizationId, + request, +}: { + assetId: AssetReminder["assetId"]; + organizationId: AssetReminder["organizationId"]; + request: Request; +}) { + try { + const reminders = await db.assetReminder.findMany({ + where: { + assetId, + organizationId, + alertDateTime: { + gte: new Date(), + }, + }, + take: 2, + include: ASSET_REMINDER_INCLUDE_FIELDS, + orderBy: { alertDateTime: "desc" }, + }); + + return reminders.map((reminder) => ({ + ...reminder, + displayDate: getDateTimeFormat(request, { + dateStyle: "short", + timeStyle: "short", + }).format(reminder.alertDateTime), + })); + } catch (cause) { + throw new ShelfError({ + cause, + message: "Something went wrong while getting asset reminders.", + label, + }); + } +} diff --git a/app/modules/asset-reminder/utils.server.ts b/app/modules/asset-reminder/utils.server.ts new file mode 100644 index 000000000..94d72d4ff --- /dev/null +++ b/app/modules/asset-reminder/utils.server.ts @@ -0,0 +1,87 @@ +import type { AssetReminder } from "@prisma/client"; +import { json, redirect } from "@remix-run/node"; +import { DateTime } from "luxon"; +import { z } from "zod"; +import { setReminderSchema } from "~/components/asset-reminder/set-or-edit-reminder-dialog"; +import { checkExhaustiveSwitch } from "~/utils/check-exhaustive-switch"; +import { getHints } from "~/utils/client-hints"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { data, parseData, safeRedirect } from "~/utils/http.server"; +import { deleteAssetReminder, editAssetReminder } from "./service.server"; + +/** + * This function handles the editing and deleting of reminders.. + * It is currently used in the assets/$assetId/reminders & reminders index. + */ +export async function resolveRemindersActions({ + request, + organizationId, + userId, +}: { + request: Request; + organizationId: AssetReminder["organizationId"]; + userId: AssetReminder["createdById"]; +}) { + const formData = await request.formData(); + + const { intent } = parseData( + formData, + z.object({ intent: z.enum(["edit-reminder", "delete-reminder"]) }) + ); + + switch (intent) { + case "edit-reminder": { + const { redirectTo, ...payload } = parseData( + formData, + 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, + teamMembers: payload.teamMembers, + alertDateTime, + organizationId, + }); + + sendNotification({ + title: "Reminder updated", + message: "Your asset reminder has been updated successfully", + icon: { name: "success", variant: "success" }, + senderId: userId, + }); + + return redirect(safeRedirect(redirectTo)); + } + + 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: userId, + }); + + return json(data({ success: true })); + } + + default: { + checkExhaustiveSwitch(intent); + return json(data(null)); + } + } +} diff --git a/app/modules/asset-reminder/worker.server.ts b/app/modules/asset-reminder/worker.server.ts new file mode 100644 index 000000000..9300f1cfb --- /dev/null +++ b/app/modules/asset-reminder/worker.server.ts @@ -0,0 +1,152 @@ +import type { Prisma, User } from "@prisma/client"; +import type PgBoss from "pg-boss"; +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"; +import { createNote } from "../note/service.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; + +type UserToEmail = Pick & { + isOwner?: boolean; +}; + +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 + .filter((tm) => !!tm.user) + .map((teamMember) => teamMember.user! as UserToEmail); + + const hasTeamMemberWithoutUser = reminder.teamMembers.some( + (tm) => !tm.user + ); + + /** + * If there is some teamMember without a user associated + * that means the access has been revoked to that teamMember. + * Then, in this case we have to send an email to the owner with special note. + */ + if (hasTeamMemberWithoutUser) { + const owner = await db.user.findFirst({ + where: { + userOrganizations: { + some: { + organizationId: reminder.organizationId, + roles: { has: "OWNER" }, + }, + }, + }, + select: { email: true, firstName: true, lastName: true }, + }); + + if (!owner) { + throw new ShelfError({ + cause: null, + message: "No owner found", + label: "Asset Scheduler", + }); + } + + usersToSendEmail.push({ + ...owner, + isOwner: true, + }); + } + + /** 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, + isOwner: user.isOwner, + }), + html: assetAlertEmailHtmlString({ + asset: reminder.asset, + user, + reminder, + workspaceName: reminder.organization.name, + isOwner: user.isOwner, + }), + }) + ), + createNote({ + assetId: reminder.assetId, + userId: reminder.createdById, + type: "UPDATE", + content: `**System** has sent **${reminder.name.trim()}** reminder.`, + }), + ]); + }, +}; + +/** + * 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/modules/asset/query.server.ts b/app/modules/asset/query.server.ts index f154f3c0c..642ea745d 100644 --- a/app/modules/asset/query.server.ts +++ b/app/modules/asset/query.server.ts @@ -1234,7 +1234,22 @@ export const assetQueryFragment = Prisma.sql` FROM public."AssetCustomFieldValue" acfv JOIN public."CustomField" cf ON acfv."customFieldId" = cf.id WHERE acfv."assetId" = a.id AND cf.active = true - ) AS "customFields" + ) AS "customFields", + ( + SELECT jsonb_build_object( + 'id', ar.id, + 'name', ar.name, + 'message', ar.message, + 'alertDateTime', ar."alertDateTime" + ) + FROM public."AssetReminder" ar + WHERE + ar."assetId" = a.id + AND ar."alertDateTime" >= NOW() AT TIME ZONE 'UTC' + ORDER BY + ar."alertDateTime" ASC + LIMIT 1 + ) AS upcomingReminder `; export const assetQueryJoins = Prisma.sql` @@ -1283,7 +1298,8 @@ export const assetReturnFragment = Prisma.sql` 'tags', aq.tags, 'location', jsonb_build_object('name', aq."locationName"), 'custody', aq.custody, - 'customFields', COALESCE(aq."customFields", '[]'::jsonb) + 'customFields', COALESCE(aq."customFields", '[]'::jsonb), + 'upcomingReminder', aq.upcomingReminder ) ) AS assets `; diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 6b6fbd25f..dec22c5f4 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -98,10 +98,12 @@ import type { UpdateAssetPayload, } from "./types"; import { + formatAssetsRemindersDates, getAssetsWhereInput, getLocationUpdateNoteContent, } from "./utils.server"; import type { Column } from "../asset-index-settings/helpers"; +import { cancelAssetReminderScheduler } from "../asset-reminder/scheduler.server"; import { createKitsIfNotExists } from "../kit/service.server"; import { createNote } from "../note/service.server"; @@ -521,7 +523,7 @@ export async function getAdvancedPaginatedAndFilterableAssets({ totalAssets, perPage: take, page, - assets, + assets: formatAssetsRemindersDates({ assets, request }), totalPages, cookie, }; @@ -902,9 +904,16 @@ export async function deleteAsset({ organizationId, }: Pick & { organizationId: Organization["id"] }) { try { - return await db.asset.deleteMany({ + const deletedAsset = await db.asset.delete({ where: { id, organizationId }, + select: { + reminders: { + select: { alertDateTime: true, activeSchedulerReference: true }, + }, + }, }); + + await Promise.all(deletedAsset.reminders.map(cancelAssetReminderScheduler)); } catch (cause) { throw new ShelfError({ cause, diff --git a/app/modules/asset/types.ts b/app/modules/asset/types.ts index 72315f235..e2c019473 100644 --- a/app/modules/asset/types.ts +++ b/app/modules/asset/types.ts @@ -10,6 +10,7 @@ import type { Tag, User, CustomFieldType, + AssetReminder, } from "@prisma/client"; import type { Return } from "@prisma/client/runtime/library"; import type { assetIndexFields } from "./fields"; @@ -140,6 +141,12 @@ export type AdvancedIndexAsset = Pick< categories: Pick[] | null; }; })[]; + upcomingReminder?: Pick< + AssetReminder, + "id" | "alertDateTime" | "name" | "message" + > & { + displayDate: string; + }; }; // Type for the entire query result export type AdvancedIndexQueryResult = Array<{ diff --git a/app/modules/asset/utils.server.ts b/app/modules/asset/utils.server.ts index 0519c1195..75ef8099f 100644 --- a/app/modules/asset/utils.server.ts +++ b/app/modules/asset/utils.server.ts @@ -1,7 +1,9 @@ import type { Asset, AssetStatus, Location, Prisma } from "@prisma/client"; import { z } from "zod"; import { filterOperatorSchema } from "~/components/assets/assets-index/advanced-filters/schema"; +import { getDateTimeFormat } from "~/utils/client-hints"; import { getParamsValues } from "~/utils/list"; +import type { AdvancedIndexAsset } from "./types"; import type { Column } from "../asset-index-settings/helpers"; export function getLocationUpdateNoteContent({ @@ -205,3 +207,32 @@ export function validateAdvancedFilterParams( return validatedParams; } + +export function formatAssetsRemindersDates({ + assets, + request, +}: { + assets: AdvancedIndexAsset[]; + request: Request; +}) { + if (!assets.length) { + return assets; + } + + return assets.map((asset) => { + if (!asset.upcomingReminder) { + return asset; + } + + return { + ...asset, + upcomingReminder: { + ...asset.upcomingReminder, + displayDate: getDateTimeFormat(request, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(asset.upcomingReminder.alertDateTime)), + }, + }; + }); +} diff --git a/app/modules/team-member/service.server.ts b/app/modules/team-member/service.server.ts index 8011518e1..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,6 +163,14 @@ export async function getTeamMembers(params: { orderBy: { createdAt: "desc" }, include: { custodies: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + profilePicture: true, + }, + }, }, }), @@ -182,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); @@ -198,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.overview.tsx b/app/routes/_layout+/assets.$assetId.overview.tsx index 00bb54aa8..4467c5ab7 100644 --- a/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/app/routes/_layout+/assets.$assetId.overview.tsx @@ -10,6 +10,7 @@ import { useFetcher, useLoaderData } from "@remix-run/react"; import { useZorm } from "react-zorm"; import { z } from "zod"; import { CustodyCard } from "~/components/assets/asset-custody-card"; +import { AssetReminderCards } from "~/components/assets/asset-reminder-cards"; import { Switch } from "~/components/forms/switch"; import Icon from "~/components/icons/icon"; import ContextualModal from "~/components/layout/contextual-modal"; @@ -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,12 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { organizationId, }); + const reminders = await getRemindersForOverviewPage({ + assetId: id, + organizationId, + request, + }); + const booking = asset.bookings.length > 0 ? asset.bookings[0] : undefined; let currentBooking: any = null; @@ -164,6 +172,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { locale, timeZone, qrObj, + reminders, }) ); } catch (cause) { @@ -449,6 +458,8 @@ export default function AssetOverview() { + + {asset?.kit?.name ? (
diff --git a/app/routes/_layout+/assets.$assetId.reminders.tsx b/app/routes/_layout+/assets.$assetId.reminders.tsx new file mode 100644 index 000000000..221c387d4 --- /dev/null +++ b/app/routes/_layout+/assets.$assetId.reminders.tsx @@ -0,0 +1,96 @@ +import { json } from "@remix-run/node"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import RemindersTable from "~/components/asset-reminder/reminders-table"; +import type { HeaderData } from "~/components/layout/header/types"; +import { getPaginatedAndFilterableReminders } from "~/modules/asset-reminder/service.server"; +import { resolveRemindersActions } from "~/modules/asset-reminder/utils.server"; +import { getDateTimeFormat } from "~/utils/client-hints"; +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, request, params }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + const { assetId } = getParams(params, z.object({ assetId: z.string() }), { + additionalData: { userId }, + }); + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.asset, + action: PermissionAction.read, + }); + + const { reminders, totalReminders, page, perPage, totalPages } = + await getPaginatedAndFilterableReminders({ + organizationId, + request, + where: { assetId }, + }); + + const header: HeaderData = { title: "Reminders" }; + const modelName = { + singular: "reminder", + plural: "reminders", + }; + + const assetReminders = reminders.map((reminder) => ({ + ...reminder, + displayDate: getDateTimeFormat(request, { + dateStyle: "short", + timeStyle: "short", + }).format(reminder.alertDateTime), + })); + + return json( + data({ + header, + modelName, + items: assetReminders, + totalItems: totalReminders, + page, + perPage, + totalPages, + }) + ); + } catch (cause) { + const reason = makeShelfError(cause, { userId, assetId }); + throw json(error(reason), { status: reason.status }); + } +} + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.asset, + action: PermissionAction.update, + }); + + return await resolveRemindersActions({ + request, + organizationId, + userId, + }); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} + +export default function AssetReminders() { + return ; +} diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 20bfa1da8..58b3cb18e 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -6,8 +6,10 @@ 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"; import ActionsDropdown from "~/components/assets/actions-dropdown"; import { AssetImage } from "~/components/assets/asset-image"; import { AssetStatusBadge } from "~/components/assets/asset-status-badge"; @@ -24,14 +26,21 @@ import { getAsset, relinkQrCode, } from "~/modules/asset/service.server"; +import { createAssetReminder } from "~/modules/asset-reminder/service.server"; 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"; +import { + error, + getParams, + data, + parseData, + safeRedirect, +} from "~/utils/http.server"; import { PermissionAction, PermissionEntity, @@ -108,12 +117,13 @@ export async function action({ context, request, params }: ActionFunctionArgs) { const { intent } = parseData( formData, - z.object({ intent: z.enum(["delete", "relink-qr-code"]) }) + z.object({ intent: z.enum(["delete", "relink-qr-code", "set-reminder"]) }) ); const intent2ActionMap: { [K in typeof intent]: PermissionAction } = { delete: PermissionAction.delete, "relink-qr-code": PermissionAction.update, + "set-reminder": PermissionAction.update, }; const { organizationId } = await requirePermission({ @@ -174,6 +184,41 @@ export async function action({ context, request, params }: ActionFunctionArgs) { return json(data({ success: true })); } + case "set-reminder": { + const { redirectTo, ...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, + }); + + sendNotification({ + title: "Reminder created", + message: "A reminder for you asset has been created successfully.", + icon: { name: "success", variant: "success" }, + senderId: authSession.userId, + }); + + return redirect(safeRedirect(redirectTo)); + } + default: { checkExhaustiveSwitch(intent); return json(data(null)); @@ -205,6 +250,7 @@ export default function AssetDetailsPage() { { to: "overview", content: "Overview" }, { to: "activity", content: "Activity" }, { to: "bookings", content: "Bookings" }, + { to: "reminders", content: "Reminders" }, ]; /** Due to some conflict of types between prisma and remix, we need to use the SerializeFrom type diff --git a/app/routes/_layout+/reminders._index.tsx b/app/routes/_layout+/reminders._index.tsx new file mode 100644 index 000000000..10743b4e8 --- /dev/null +++ b/app/routes/_layout+/reminders._index.tsx @@ -0,0 +1,111 @@ +import { json } from "@remix-run/node"; +import type { + MetaFunction, + LoaderFunctionArgs, + ActionFunctionArgs, +} from "@remix-run/node"; +import RemindersTable from "~/components/asset-reminder/reminders-table"; +import Header from "~/components/layout/header"; +import type { HeaderData } from "~/components/layout/header/types"; +import { getPaginatedAndFilterableReminders } from "~/modules/asset-reminder/service.server"; +import { resolveRemindersActions } from "~/modules/asset-reminder/utils.server"; +import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { getDateTimeFormat } from "~/utils/client-hints"; +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"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.assetReminders, + action: PermissionAction.read, + }); + + const { page, perPage, reminders, totalPages, totalReminders, search } = + await getPaginatedAndFilterableReminders({ + organizationId, + request, + }); + + const header: HeaderData = { title: "Reminders" }; + const modelName = { + singular: "reminder", + plural: "reminders", + }; + + const assetReminders = reminders.map((reminder) => ({ + ...reminder, + displayDate: getDateTimeFormat(request, { + dateStyle: "short", + timeStyle: "short", + }).format(reminder.alertDateTime), + })); + + return json( + data({ + header, + modelName, + items: assetReminders, + totalItems: totalReminders, + page, + perPage, + totalPages, + searchFieldLabel: "Search reminders", + searchFieldTooltip: { + title: "Search reminders", + text: "Search reminders by reminder name, message, asset name or team member name. Separate your keywords by a comma(,) to search with OR condition. For example: searching 'Laptop, maintenance' will find reminders matching any of these terms.", + }, + search, + }) + ); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + throw json(error(reason), { status: reason.status }); + } +} + +export async function action({ context, request }: ActionFunctionArgs) { + const authSession = context.getSession(); + const userId = authSession.userId; + + try { + const { organizationId } = await requirePermission({ + userId, + request, + entity: PermissionEntity.asset, + action: PermissionAction.update, + }); + + return await resolveRemindersActions({ + request, + organizationId, + userId, + }); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} + +export const meta: MetaFunction = ({ data }) => [ + { title: appendToMetaTitle(data?.header.title) }, +]; + +export default function Reminders() { + return ( + <> +
+ + + ); +} diff --git a/app/routes/_layout+/reminders.tsx b/app/routes/_layout+/reminders.tsx new file mode 100644 index 000000000..35b380f65 --- /dev/null +++ b/app/routes/_layout+/reminders.tsx @@ -0,0 +1,16 @@ +import { Link, Outlet } from "@remix-run/react"; +import { ErrorContent } from "~/components/errors"; + +export function loader() { + return null; +} + +export const handle = { + breadcrumb: () => Reminders, +}; + +export default function RemindersPage() { + return ; +} + +export const ErrorBoundary = () => ; diff --git a/app/routes/api+/model-filters.ts b/app/routes/api+/model-filters.ts index cb2d7f8cc..a690ad5d1 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(), + userWithAdminAndOwnerOnly: z.coerce.boolean().optional(), // To get only the teamMembers which are admin or owner }), 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,32 @@ 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.userWithAdminAndOwnerOnly) { + where.AND = [ + { user: { isNot: null } }, + { + user: { + userOrganizations: { + some: { + AND: [ + { organizationId }, + { roles: { hasSome: ["ADMIN", "OWNER"] } }, + ], + }, + }, + }, + }, + ]; + } } else { where.OR.push({ [queryKey]: { contains: queryValue, mode: "insensitive" }, @@ -98,7 +118,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" diff --git a/app/routes/api+/reminders.team-members.ts b/app/routes/api+/reminders.team-members.ts new file mode 100644 index 000000000..a9b4a7a9b --- /dev/null +++ b/app/routes/api+/reminders.team-members.ts @@ -0,0 +1,71 @@ +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, + email: true, + firstName: true, + lastName: true, + profilePicture: true, + }, + }, +} satisfies Prisma.TeamMemberInclude; + +export type ReminderTeamMember = 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 }); + } +} diff --git a/app/utils/csv.server.ts b/app/utils/csv.server.ts index 69cc295e1..b4b362917 100644 --- a/app/utils/csv.server.ts +++ b/app/utils/csv.server.ts @@ -359,6 +359,10 @@ export const buildCsvExportDataFromAssets = ({ case "availableToBook": value = asset.availableToBook ? "Yes" : "No"; break; + case "upcomingReminder": { + value = asset.upcomingReminder?.displayDate; + break; + } default: checkExhaustiveSwitch(fieldName); value = ""; diff --git a/app/utils/error.ts b/app/utils/error.ts index 238533b40..df95725d9 100644 --- a/app/utils/error.ts +++ b/app/utils/error.ts @@ -91,7 +91,9 @@ 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 Reminder" + | "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 diff --git a/app/utils/permissions/permission.data.ts b/app/utils/permissions/permission.data.ts index 03c978446..dd08be866 100644 --- a/app/utils/permissions/permission.data.ts +++ b/app/utils/permissions/permission.data.ts @@ -33,6 +33,7 @@ export enum PermissionEntity { note = "note", scan = "scan", custody = "custody", + assetReminders = "assetReminders", } //this will come from DB eventually @@ -64,6 +65,7 @@ export const Role2PermissionMap: { [PermissionEntity.note]: [], [PermissionEntity.scan]: [], [PermissionEntity.custody]: [], + [PermissionEntity.assetReminders]: [], }, [OrganizationRoles.SELF_SERVICE]: { [PermissionEntity.asset]: [PermissionAction.read, PermissionAction.custody], @@ -94,5 +96,6 @@ export const Role2PermissionMap: { [PermissionEntity.note]: [], [PermissionEntity.scan]: [], [PermissionEntity.custody]: [PermissionAction.read], + [PermissionEntity.assetReminders]: [], }, };