diff --git a/app/components/errors/index.tsx b/app/components/errors/index.tsx index 1767a891b..e025dd83e 100644 --- a/app/components/errors/index.tsx +++ b/app/components/errors/index.tsx @@ -63,17 +63,17 @@ export const ErrorBoundryComponent = ({ } } else if (error instanceof Error) { return ( - <> - - {NODE_ENV === "development" ?
{error.stack}
: null} - + {error.message} + ) : ( + "Please try again and if the issue persists, contact support" + ) + } + /> + ); } else { return ( diff --git a/app/emails/bookings-updates-template.tsx b/app/emails/bookings-updates-template.tsx index d55808453..444cf2313 100644 --- a/app/emails/bookings-updates-template.tsx +++ b/app/emails/bookings-updates-template.tsx @@ -1,8 +1,6 @@ import { Button, Html, - Text, - Link, Head, render, Container, @@ -11,6 +9,7 @@ import { import type { ClientHint } from "~/modules/booking/types"; import { getDateTimeFormatFromHints } from "~/utils/client-hints"; import { SERVER_URL } from "~/utils/env"; +import { AdminFooter, UserFooter } from "./components/footers"; import { LogoForEmail } from "./logo"; import { styles } from "./styles"; import type { BookingForEmail } from "./types"; @@ -21,6 +20,7 @@ interface Props { assetCount: number; hints: ClientHint; hideViewButton?: boolean; + isAdminEmail?: boolean; } export function BookingUpdatesEmailTemplate({ @@ -29,6 +29,7 @@ export function BookingUpdatesEmailTemplate({ hints, assetCount, hideViewButton = false, + isAdminEmail = false, }: Props) { const fromDate = getDateTimeFormatFromHints(hints, { dateStyle: "short", @@ -95,34 +96,11 @@ export function BookingUpdatesEmailTemplate({ )} - - This email was sent to{" "} - - {booking.custodianUser!.email} - {" "} - because it is part of the workspace{" "} - - "{booking.organization.name}" - - .
If you think you weren’t supposed to have received this email - please{" "} - - contact the owner - {" "} - of the workspace. -
- - {" "} - © 2024 Shelf.nu - + {isAdminEmail ? ( + + ) : ( + + )} ); @@ -138,6 +116,7 @@ export const bookingUpdatesTemplateString = ({ assetCount, hints, hideViewButton = false, + isAdminEmail = false, }: Props) => render( ); diff --git a/app/emails/components/footers.tsx b/app/emails/components/footers.tsx new file mode 100644 index 000000000..ac6c1d9b3 --- /dev/null +++ b/app/emails/components/footers.tsx @@ -0,0 +1,53 @@ +import { Text, Link } from "@react-email/components"; +import type { BookingForEmail } from "../types"; + +/** Footer used when sending normal user emails */ +export const UserFooter = ({ booking }: { booking: BookingForEmail }) => ( + <> + + This email was sent to{" "} + + {booking.custodianUser!.email} + {" "} + because it is part of the workspace{" "} + + "{booking.organization.name}" + + .
If you think you weren’t supposed to have received this email + please{" "} + + contact the owner + {" "} + of the workspace. +
+ + {" "} + © 2024 Shelf.nu + + +); + +/** Footer used when sending admin user emails */ +export const AdminFooter = ({ booking }: { booking: BookingForEmail }) => ( + <> + + This email was sent to you because you are the OWNER or ADMIN of the + workspace{" "} + + "{booking.organization.name}" + + .
If you think you weren’t supposed to have received this email + please contact support. +
+ + {" "} + © 2024 Shelf.nu + + +); diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index 1e3383139..ba5980955 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -108,7 +108,11 @@ export async function getAsset({ return asset; } -export async function getAssets({ +/** + * Fetches assets from AssetSearchView + * This is used to have a more advanced search however its less performant + */ +export async function getAssetsFromView({ organizationId, page = 1, perPage = 8, @@ -296,6 +300,185 @@ export async function getAssets({ return { assets: assetSearch.map((a) => a.asset), totalAssets }; } +/** + * Fetches assets directly from asset table + */ +export async function getAssets({ + organizationId, + page = 1, + perPage = 8, + search, + categoriesIds, + tagsIds, + bookingFrom, + bookingTo, + hideUnavailable, + unhideAssetsBookigIds, // works in conjuction with hideUnavailable, to show currentbooking assets +}: { + organizationId: Organization["id"]; + + /** Page number. Starts at 1 */ + page: number; + + /** Assets to be loaded per page */ + perPage?: number; + + search?: string | null; + + categoriesIds?: Category["id"][] | null; + tagsIds?: Tag["id"][] | null; + hideUnavailable?: Asset["availableToBook"]; + bookingFrom?: Booking["from"]; + bookingTo?: Booking["to"]; + unhideAssetsBookigIds?: Booking["id"][]; +}) { + const skip = page > 1 ? (page - 1) * perPage : 0; + const take = perPage >= 1 && perPage <= 100 ? perPage : 20; // min 1 and max 25 per page + + /** Default value of where. Takes the assetss belonging to current user */ + let where: Prisma.AssetWhereInput = { organizationId }; + + /** If the search string exists, add it to the where object */ + if (search) { + const words = search.trim().replace(/ +/g, " "); //replace multiple spaces into 1 + where.title = words; + } + + if (categoriesIds && categoriesIds.length > 0) { + if (categoriesIds.includes("uncategorized")) { + where.OR = [ + { + categoryId: { + in: categoriesIds, + }, + }, + { + categoryId: null, + }, + ]; + } else { + where.categoryId = { + in: categoriesIds, + }; + } + } + const unavailableBookingStatuses = [ + BookingStatus.DRAFT, + BookingStatus.RESERVED, + BookingStatus.ONGOING, + ]; + if (hideUnavailable) { + //not disabled for booking + where.availableToBook = true; + //not assigned to team meber + where.custody = null; + if (bookingFrom && bookingTo) { + //reserved during that time + where.bookings = { + none: { + ...(unhideAssetsBookigIds?.length && { + id: { notIn: unhideAssetsBookigIds }, + }), + status: { in: unavailableBookingStatuses }, + OR: [ + { + from: { lte: bookingTo }, + to: { gte: bookingFrom }, + }, + { + from: { gte: bookingFrom }, + to: { lte: bookingTo }, + }, + ], + }, + }; + } + } + if (hideUnavailable === true && (!bookingFrom || !bookingTo)) { + throw new ShelfStackError({ + message: "booking dates are needed to hide unavailable assets", + }); + } + if (bookingFrom && bookingTo) { + where.availableToBook = true; + } + + if (tagsIds && tagsIds.length > 0) { + where.tags = { + some: { + id: { + in: tagsIds, + }, + }, + }; + } + + const [assets, totalAssets] = await db.$transaction([ + /** Get the assets */ + db.asset.findMany({ + skip, + take, + where, + include: { + category: true, + tags: true, + location: { + select: { + name: true, + }, + }, + custody: { + select: { + custodian: { + select: { + name: true, + user: { + select: { + profilePicture: true, + }, + }, + }, + }, + }, + }, + ...(bookingTo && bookingFrom + ? { + bookings: { + where: { + status: { in: unavailableBookingStatuses }, + OR: [ + { + from: { lte: bookingTo }, + to: { gte: bookingFrom }, + }, + { + from: { gte: bookingFrom }, + to: { lte: bookingTo }, + }, + ], + }, + take: 1, //just to show in UI if its booked, so take only 1, also at a given slot only 1 booking can be created for an asset + select: { + from: true, + to: true, + status: true, + id: true, + name: true, + }, + }, + } + : {}), + }, + orderBy: { createdAt: "desc" }, + }), + + /** Count them */ + db.asset.count({ where }), + ]); + + return { assets, totalAssets }; +} + export async function createAsset({ title, description, @@ -818,12 +1001,18 @@ export const getPaginatedAndFilterableAssets = async ({ organizationId, excludeCategoriesQuery = false, excludeTagsQuery = false, + excludeSearchFromView = false, }: { request: LoaderFunctionArgs["request"]; organizationId: Organization["id"]; extraInclude?: Prisma.AssetInclude; excludeCategoriesQuery?: boolean; excludeTagsQuery?: boolean; + /** + * Set to true if you want the query to be performed by directly accessing the assets table + * instead of the AssetSearchView + */ + excludeSearchFromView?: boolean; }) => { const searchParams = getCurrentSearchParams(request); const { @@ -842,7 +1031,12 @@ export const getPaginatedAndFilterableAssets = async ({ const cookie = await updateCookieWithPerPage(request, perPageParam); const { perPage } = cookie; - const { assets, totalAssets } = await getAssets({ + let getFunction = getAssetsFromView; + if (excludeSearchFromView) { + getFunction = getAssets; + } + + const { assets, totalAssets } = await getFunction({ organizationId, page, perPage, diff --git a/app/modules/booking/email-helpers.ts b/app/modules/booking/email-helpers.ts index 81227c50a..75dc4f44d 100644 --- a/app/modules/booking/email-helpers.ts +++ b/app/modules/booking/email-helpers.ts @@ -83,7 +83,7 @@ export const assetReservedEmailContent = ({ to, bookingId, assetsCount, - emailContent: `Booking confirmation for ${custodian}.`, + emailContent: `Booking reservation for ${custodian}.`, }); /** diff --git a/app/modules/booking/service.server.ts b/app/modules/booking/service.server.ts index e97e85051..08afeeb4f 100644 --- a/app/modules/booking/service.server.ts +++ b/app/modules/booking/service.server.ts @@ -21,6 +21,7 @@ import { sendCheckinReminder, } from "./email-helpers"; import type { ClientHint, SchedulerData } from "./types"; +import { getOrganizationAdminsEmails } from "../organization"; /** Includes needed for booking to have all data required for emails */ export const bookingIncludeForEmails = { @@ -96,7 +97,8 @@ export const upsertBooking = async ( | "custodianUserId" > & { assetIds: Asset["id"][] } >, - hints: ClientHint + hints: ClientHint, + isSelfService: boolean = false ) => { const { assetIds, @@ -247,11 +249,37 @@ export const upsertBooking = async ( }); let html = bookingUpdatesTemplateString({ booking: res, - heading: `Booking confirmation for ${custodian}`, + heading: `Booking reservation for ${custodian}`, assetCount: res.assets.length, hints, }); + /** Here we need to check if the custodian is different than the admin and send email to the admin in case they are different */ + if (isSelfService) { + const adminsEmails = await getOrganizationAdminsEmails({ + organizationId: res.organizationId, + }); + + const adminSubject = `Booking reservation request (${res.name}) by ${custodian} - shelf.nu`; + + /** Pushing admins emails to promises */ + promises.push( + sendEmail({ + to: adminsEmails.join(","), + subject: adminSubject, + text, + /** We need to invoke this function separately for the admin email as the footer of emails is different */ + html: bookingUpdatesTemplateString({ + booking: res, + heading: `Booking reservation for ${custodian}`, + assetCount: res.assets.length, + hints, + isAdminEmail: true, + }), + }) + ); + } + if (data.status === BookingStatus.COMPLETE) { subject = `Booking completed (${res.name}) - shelf.nu`; text = completedBookingEmailContent({ @@ -502,13 +530,24 @@ export const removeAssets = async ( /** When removing an asset from a booking we need to make sure to set their status back to available * This is needed because the user is allowed to remove an asset from a booking that is ongoing, which means the asset status will be CHECKED_OUT * So we need to set it back to AVAILABLE + * We only do that if the booking we removed it from is ongoing or overdue. + * Reason is that the user can add an asset to a draft booking and remove it and that will reset its status back to available, which shouldnt happen + * https://github.com/Shelf-nu/shelf.nu/issues/703#issuecomment-1944315975 + * * Because prisma doesnt support transactional execution of nested queries, we need to do them in 2 steps, because if the disconnect runs first, * the updateMany will not find the assets in the booking anymore and wont update them */ - await db.asset.updateMany({ - where: { id: { in: assetIds } }, - data: { status: AssetStatus.AVAILABLE }, - }); + + if ( + b.status === BookingStatus.ONGOING || + b.status === BookingStatus.OVERDUE + ) { + await db.asset.updateMany({ + where: { id: { in: assetIds } }, + data: { status: AssetStatus.AVAILABLE }, + }); + } + return b; }; diff --git a/app/modules/organization/service.server.ts b/app/modules/organization/service.server.ts index 45d7bea6b..fcb2c6b37 100644 --- a/app/modules/organization/service.server.ts +++ b/app/modules/organization/service.server.ts @@ -183,3 +183,27 @@ export const getUserOrganizations = async ({ userId }: { userId: string }) => { return userOrganizations; }; + +export const getOrganizationAdminsEmails = async ({ + organizationId, +}: { + organizationId: string; +}) => { + const admins = await db.userOrganization.findMany({ + where: { + organizationId, + roles: { + hasSome: [OrganizationRoles.OWNER, OrganizationRoles.ADMIN], + }, + }, + select: { + user: { + select: { + email: true, + }, + }, + }, + }); + + return admins.map((a) => a.user.email); +}; diff --git a/app/routes/_auth+/join.tsx b/app/routes/_auth+/join.tsx index f87dcb000..744a977bc 100644 --- a/app/routes/_auth+/join.tsx +++ b/app/routes/_auth+/join.tsx @@ -38,8 +38,12 @@ const JoinFormSchema = z .string() .email("invalid-email") .transform((email) => email.toLowerCase()), - password: z.string().min(8, "password-too-short"), - confirmPassword: z.string().min(8, "password-too-short"), + password: z + .string() + .min(8, "Your password is too short. Min 8 characters are required."), + confirmPassword: z + .string() + .min(8, "Your password is too short. Min 8 characters are required."), redirectTo: z.string().optional(), }) .superRefine(({ password, confirmPassword }, ctx) => { diff --git a/app/routes/_layout+/bookings.$bookingId.add-assets.tsx b/app/routes/_layout+/bookings.$bookingId.add-assets.tsx index 8c384ec65..cb4b3a53b 100644 --- a/app/routes/_layout+/bookings.$bookingId.add-assets.tsx +++ b/app/routes/_layout+/bookings.$bookingId.add-assets.tsx @@ -67,6 +67,7 @@ export const loader = async ({ organizationId, excludeCategoriesQuery: true, excludeTagsQuery: true, + excludeSearchFromView: true, }); const modelName = { diff --git a/app/routes/_layout+/bookings.$bookingId.tsx b/app/routes/_layout+/bookings.$bookingId.tsx index 792b13210..6a9185b69 100644 --- a/app/routes/_layout+/bookings.$bookingId.tsx +++ b/app/routes/_layout+/bookings.$bookingId.tsx @@ -220,9 +220,13 @@ export async function action({ context, request, params }: ActionFunctionArgs) { action: intent2ActionMap[intent], }); const id = getRequiredParam(params, "bookingId"); + const isSelfService = role === OrganizationRoles.SELF_SERVICE; + + const headers = [ setCookie(await setSelectedOrganizationIdCookie(organizationId)), ]; + switch (intent) { case "save": const result = await NewBookingFormSchema().safeParseAsync( @@ -280,7 +284,8 @@ export async function action({ context, request, params }: ActionFunctionArgs) { case "reserve": await upsertBooking( { id, status: BookingStatus.RESERVED }, - getClientHint(request) + getClientHint(request), + isSelfService ); sendNotification({ title: "Booking reserved", @@ -295,7 +300,6 @@ export async function action({ context, request, params }: ActionFunctionArgs) { } ); case "delete": - const isSelfService = role === OrganizationRoles.SELF_SERVICE; if (isSelfService) { /** * When user is self_service we need to check if the booking belongs to them and only then allow them to delete it. diff --git a/app/routes/_layout+/locations.$locationId.add-assets.tsx b/app/routes/_layout+/locations.$locationId.add-assets.tsx index bb3524f31..d8673cbae 100644 --- a/app/routes/_layout+/locations.$locationId.add-assets.tsx +++ b/app/routes/_layout+/locations.$locationId.add-assets.tsx @@ -52,6 +52,7 @@ export const loader = async ({ organizationId, excludeCategoriesQuery: true, excludeTagsQuery: true, + excludeSearchFromView: true, }); const modelName = { diff --git a/app/utils/list.ts b/app/utils/list.ts index bf9888368..5c0ba13b4 100644 --- a/app/utils/list.ts +++ b/app/utils/list.ts @@ -18,7 +18,11 @@ export const getParamsValues = (searchParams: URLSearchParams) => ({ ? searchParams.get("hideUnavailable") == "true" : undefined, unhideAssetsBookigIds: searchParams.getAll("unhideAssetsBookigIds") || [], - status: (searchParams.get("status") || null) as BookingStatus | null, + + status: + searchParams.get("status") === "ALL" // If the value is "ALL", we just remove the param + ? null + : (searchParams.get("status") as BookingStatus | null), }); /** Generates prev & next links */