diff --git a/app/components/errors/index.tsx b/app/components/errors/index.tsx index 04c4d8807..fcd7ceda9 100644 --- a/app/components/errors/index.tsx +++ b/app/components/errors/index.tsx @@ -1,5 +1,7 @@ import { isRouteErrorResponse, useRouteError } from "@remix-run/react"; import { NODE_ENV } from "~/utils/env"; +import type { ShelfStackError} from "~/utils/error"; +import { isShelfStackError } from "~/utils/error"; import { ErrorContent } from "./content"; export interface ErrorContentProps { @@ -12,7 +14,11 @@ export const ErrorBoundryComponent = ({ title, message, }: ErrorContentProps) => { - const error = useRouteError(); + const error: Error = useRouteError() as Error; + if (isShelfStackError(error)) { + title = title || (error as ShelfStackError).title + message = message || error.message + } /** 404 ERROR */ if (isRouteErrorResponse(error)) switch (error.status) { diff --git a/app/database/manual-migrations/master-data/seed-role.server.ts b/app/database/manual-migrations/master-data/seed-role.server.ts index d49ebe9af..f94317e4f 100644 --- a/app/database/manual-migrations/master-data/seed-role.server.ts +++ b/app/database/manual-migrations/master-data/seed-role.server.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import type { Role } from "@prisma/client"; import { PrismaClient, Roles } from "@prisma/client"; +import { ShelfStackError } from "~/utils/error"; const prisma = new PrismaClient(); @@ -90,8 +91,7 @@ async function seed() { console.log(`Database has been seeded. šŸŒ±\n`); } catch (cause) { - console.error(cause); - throw new Error("Seed failed šŸ„²"); + throw new ShelfStackError({ message: "Seed failed šŸ„²", cause }); } } diff --git a/app/database/manual-migrations/relationship-data-updates/add-organizations-to-existing-users.server.ts b/app/database/manual-migrations/relationship-data-updates/add-organizations-to-existing-users.server.ts index f33f3fa8d..1989d0507 100644 --- a/app/database/manual-migrations/relationship-data-updates/add-organizations-to-existing-users.server.ts +++ b/app/database/manual-migrations/relationship-data-updates/add-organizations-to-existing-users.server.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import { OrganizationType, PrismaClient } from "@prisma/client"; +import { ShelfStackError } from "~/utils/error"; const prisma = new PrismaClient(); @@ -48,8 +49,7 @@ async function seed() { ); console.log(`Database has been seeded. šŸŒ±\n`); } catch (cause) { - console.error(cause); - throw new Error("Seed failed šŸ„²"); + throw new ShelfStackError({ message: "Seed failed šŸ„²", cause }); } } diff --git a/app/database/manual-migrations/relationship-data-updates/move-user-assets-to-personal-organization.server.ts b/app/database/manual-migrations/relationship-data-updates/move-user-assets-to-personal-organization.server.ts index 02b82825a..f388f5df1 100644 --- a/app/database/manual-migrations/relationship-data-updates/move-user-assets-to-personal-organization.server.ts +++ b/app/database/manual-migrations/relationship-data-updates/move-user-assets-to-personal-organization.server.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import { OrganizationType, PrismaClient } from "@prisma/client"; +import { ShelfStackError } from "~/utils/error"; const prisma = new PrismaClient(); @@ -43,8 +44,7 @@ async function seed() { ); console.log(`Database has been seeded. šŸŒ±\n`); } catch (cause) { - console.error(cause); - throw new Error("Seed failed šŸ„²"); + throw new ShelfStackError({ message: "Seed failed šŸ„²", cause }); } } diff --git a/app/database/manual-migrations/testing-data/testing-data-seed.ts b/app/database/manual-migrations/testing-data/testing-data-seed.ts index d0e75a41d..2b6be8a6d 100644 --- a/app/database/manual-migrations/testing-data/testing-data-seed.ts +++ b/app/database/manual-migrations/testing-data/testing-data-seed.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import type { User } from "@prisma/client"; import { PrismaClient } from "@prisma/client"; +import { ShelfStackError } from "~/utils/error"; const prisma = new PrismaClient(); @@ -28,8 +29,7 @@ async function seed() { }); }); } catch (cause) { - console.error(cause); - throw new Error("Seed failed šŸ„²"); + throw new ShelfStackError({ message: "Seed failed šŸ„²", cause }); } } diff --git a/app/database/seed.server.ts b/app/database/seed.server.ts index db7cb2492..ea18e15b6 100644 --- a/app/database/seed.server.ts +++ b/app/database/seed.server.ts @@ -3,6 +3,7 @@ import { PrismaClient } from "@prisma/client"; import { createClient } from "@supabase/supabase-js"; import { createUser } from "~/modules/user"; +import { ShelfStackError } from "~/utils/error"; import { createAdminRole, createUserRole, @@ -78,7 +79,7 @@ async function seed() { }); if (!user) { - throw new Error("Unable to create user"); + throw new ShelfStackError({ message: "Unable to create user" }); } console.log(`Database has been seeded. šŸŒ±\n`); @@ -86,8 +87,7 @@ async function seed() { `User added to your database šŸ‘‡ \nšŸ†”: ${user.id}\nšŸ“§: ${user.email}\nšŸ”‘: supabase` ); } catch (cause) { - console.error(cause); - throw new Error("Seed failed šŸ„²"); + throw new ShelfStackError({ message: "Seed failed šŸ„²",cause }); } } diff --git a/app/integrations/supabase/client.ts b/app/integrations/supabase/client.ts index b2cc7110a..63949a06b 100644 --- a/app/integrations/supabase/client.ts +++ b/app/integrations/supabase/client.ts @@ -5,6 +5,7 @@ import { SUPABASE_URL, SUPABASE_ANON_PUBLIC, } from "~/utils/env"; +import { ShelfStackError } from "~/utils/error"; import { isBrowser } from "~/utils/is-browser"; // āš ļø cloudflare needs you define fetch option : https://github.com/supabase/supabase-js#custom-fetch-implementation @@ -12,12 +13,12 @@ import { isBrowser } from "~/utils/is-browser"; function getSupabaseClient(supabaseKey: string, accessToken?: string) { const global = accessToken ? { - global: { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + global: { + headers: { + Authorization: `Bearer ${accessToken}`, }, - } + }, + } : {}; return createClient(SUPABASE_URL, supabaseKey, { @@ -40,8 +41,8 @@ const supabaseClient = getSupabaseClient(SUPABASE_ANON_PUBLIC); */ function getSupabaseAdmin() { if (isBrowser) - throw new Error( - "getSupabaseAdmin is not available in browser and should NOT be used in insecure environments" + throw new ShelfStackError( + { message: "getSupabaseAdmin is not available in browser and should NOT be used in insecure environments" } ); return getSupabaseClient(SUPABASE_SERVICE_ROLE); diff --git a/app/modules/asset/service.server.ts b/app/modules/asset/service.server.ts index ab253eb86..46be6299a 100644 --- a/app/modules/asset/service.server.ts +++ b/app/modules/asset/service.server.ts @@ -1,4 +1,6 @@ import { + ErrorCorrection } from "@prisma/client"; +import type { Category, Location, Note, @@ -6,9 +8,7 @@ import { Qr, Asset, User, - Tag, -} from "@prisma/client"; -import { ErrorCorrection } from "@prisma/client"; + Tag } from "@prisma/client"; import type { LoaderArgs } from "@remix-run/node"; import { db } from "~/database"; import { diff --git a/app/modules/auth/mappers.ts b/app/modules/auth/mappers.ts index e5421ebe9..f52f74ea1 100644 --- a/app/modules/auth/mappers.ts +++ b/app/modules/auth/mappers.ts @@ -1,5 +1,6 @@ import type { SupabaseAuthSession } from "~/integrations/supabase"; +import { ShelfStackError } from "~/utils/error"; import type { AuthSession } from "./types"; export function mapAuthSession( @@ -8,10 +9,10 @@ export function mapAuthSession( if (!supabaseAuthSession) return null; if (!supabaseAuthSession.refresh_token) - throw new Error("User should have a refresh token"); + throw new ShelfStackError({message:"User should have a refresh token"}); if (!supabaseAuthSession.user?.email) - throw new Error("User should have an email"); + throw new ShelfStackError({message:"User should have an email"}); return { accessToken: supabaseAuthSession.access_token, diff --git a/app/modules/user/service.server.ts b/app/modules/user/service.server.ts index 38ab90ddf..867421e4c 100644 --- a/app/modules/user/service.server.ts +++ b/app/modules/user/service.server.ts @@ -19,6 +19,7 @@ import { getCurrentSearchParams, getParamsValues, } from "~/utils"; +import { ShelfStackError } from "~/utils/error"; import { deleteProfilePicture, getPublicFileURL, @@ -323,7 +324,7 @@ export async function updateProfilePicture({ export async function deleteUser(id: User["id"]) { if (!id) { - throw new Error("User ID is required"); + throw new ShelfStackError({message:"User ID is required"}); } try { diff --git a/app/routes/_layout+/assets.$assetId.tsx b/app/routes/_layout+/assets.$assetId.tsx index 0576bd1de..adb7f39cb 100644 --- a/app/routes/_layout+/assets.$assetId.tsx +++ b/app/routes/_layout+/assets.$assetId.tsx @@ -42,6 +42,7 @@ import { import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { getDateTimeFormat } from "~/utils/client-hints"; import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { ShelfStackError } from "~/utils/error"; import { parseMarkdownToReact } from "~/utils/md.server"; import { deleteAssets } from "~/utils/storage.server"; @@ -51,7 +52,7 @@ export async function loader({ request, params }: LoaderArgs) { const asset = await getAsset({ userId, id }); if (!asset) { - throw new Response("Not Found", { status: 404 }); + throw new ShelfStackError({message:"Asset Not Found", status: 404 }); } /** We get the first QR code(for now we can only have 1) * And using the ID of tha qr code, we find the latest scan diff --git a/app/routes/_layout+/assets.$assetId_.edit.tsx b/app/routes/_layout+/assets.$assetId_.edit.tsx index 9b9e4e4bf..bfba8359d 100644 --- a/app/routes/_layout+/assets.$assetId_.edit.tsx +++ b/app/routes/_layout+/assets.$assetId_.edit.tsx @@ -22,6 +22,7 @@ import { buildTagsSet } from "~/modules/tag"; import { assertIsPost, getRequiredParam } from "~/utils"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { ShelfStackError } from "~/utils/error"; export async function loader({ request, params }: LoaderArgs) { const { userId } = await requireAuthSession(request); @@ -33,7 +34,7 @@ export async function loader({ request, params }: LoaderArgs) { const asset = await getAsset({ userId, id }); if (!asset) { - throw new Response("Not Found", { status: 404 }); + throw new ShelfStackError({message:"Not Found", status: 404 }); } const header: HeaderData = { diff --git a/app/routes/_layout+/assets._index.tsx b/app/routes/_layout+/assets._index.tsx index 08f5fd98d..f1d8bdfb6 100644 --- a/app/routes/_layout+/assets._index.tsx +++ b/app/routes/_layout+/assets._index.tsx @@ -27,6 +27,7 @@ import { requireAuthSession } from "~/modules/auth"; import { getUserByID } from "~/modules/user"; import { notFound, userFriendlyAssetStatus } from "~/utils"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { ShelfStackError } from "~/utils/error"; export interface IndexResponse { /** Page number. Starts at 1 */ @@ -90,7 +91,7 @@ export async function loader({ request }: LoaderArgs) { } if (!assets) { - throw notFound(`No assets found`); + throw new ShelfStackError({ title: "heyy!", message: `No assets found`, status: 404 }); } const header: HeaderData = { diff --git a/app/routes/_layout+/locations.$locationId.add-assets.tsx b/app/routes/_layout+/locations.$locationId.add-assets.tsx index f4ec14df4..a1bf747e5 100644 --- a/app/routes/_layout+/locations.$locationId.add-assets.tsx +++ b/app/routes/_layout+/locations.$locationId.add-assets.tsx @@ -14,6 +14,7 @@ import { } from "~/modules/asset"; import { requireAuthSession } from "~/modules/auth"; import { assertIsPost } from "~/utils"; +import { ShelfStackError } from "~/utils/error"; export const loader = async ({ request, params }: LoaderArgs) => { const { userId } = await requireAuthSession(request); @@ -90,7 +91,7 @@ export const action = async ({ request, params }: ActionArgs) => { }); if (!location) { - throw new Response("Something went wrong", { status: 500 }); + throw new ShelfStackError({message:"Something went wrong", status: 500 }); } if (asset) { diff --git a/app/routes/_layout+/locations.$locationId.tsx b/app/routes/_layout+/locations.$locationId.tsx index c619a4fd5..caf639715 100644 --- a/app/routes/_layout+/locations.$locationId.tsx +++ b/app/routes/_layout+/locations.$locationId.tsx @@ -38,6 +38,7 @@ import { } from "~/utils"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { ShelfStackError } from "~/utils/error"; export const loader = async ({ request, params }: LoaderArgs) => { const { userId } = await requireAuthSession(request); @@ -55,7 +56,7 @@ export const loader = async ({ request, params }: LoaderArgs) => { }); if (!location) { - throw new Response("Not Found", { status: 404 }); + throw new ShelfStackError({message:"Not Found", status: 404 }); } const totalItems = totalAssetsWithinLocation; diff --git a/app/routes/_layout+/locations.$locationId_.edit.tsx b/app/routes/_layout+/locations.$locationId_.edit.tsx index d0c92bf36..4f307b5a8 100644 --- a/app/routes/_layout+/locations.$locationId_.edit.tsx +++ b/app/routes/_layout+/locations.$locationId_.edit.tsx @@ -17,6 +17,7 @@ import { getLocation, updateLocation } from "~/modules/location"; import { assertIsPost, getRequiredParam } from "~/utils"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { sendNotification } from "~/utils/emitter/send-notification.server"; +import { ShelfStackError } from "~/utils/error"; import { MAX_SIZE } from "./locations.new"; export async function loader({ request, params }: LoaderArgs) { @@ -26,7 +27,7 @@ export async function loader({ request, params }: LoaderArgs) { const { location } = await getLocation({ userId, id }); if (!location) { - throw new Response("Not Found", { status: 404 }); + throw new ShelfStackError({ message: "Location Not Found", status: 404 }); } const header: HeaderData = { diff --git a/app/routes/_layout+/settings.workspace.tsx b/app/routes/_layout+/settings.workspace.tsx index e5f563b50..171991fc3 100644 --- a/app/routes/_layout+/settings.workspace.tsx +++ b/app/routes/_layout+/settings.workspace.tsx @@ -17,12 +17,13 @@ import { getUserPersonalOrganizationData, } from "~/modules/organization"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; +import { ShelfStackError } from "~/utils/error"; export const loader = async ({ request }: LoaderArgs) => { const { userId } = await requireAuthSession(request); const { organization, totalAssets, totalLocations } = await getUserPersonalOrganizationData({ userId }); - if (!organization) throw new Error("Organization not found"); + if (!organization) throw new ShelfStackError({message:"Organization not found"}); const { page, diff --git a/app/routes/api+/image.$imageId.tsx b/app/routes/api+/image.$imageId.tsx index 1bc47bfb0..ec2019e10 100644 --- a/app/routes/api+/image.$imageId.tsx +++ b/app/routes/api+/image.$imageId.tsx @@ -1,14 +1,17 @@ import type { LoaderArgs } from "@remix-run/node"; import { db } from "~/database"; import { getAuthSession } from "~/modules/auth"; +import { ShelfStackError } from "~/utils/error"; export async function loader({ request, params }: LoaderArgs) { const session = await getAuthSession(request); if (!session) - throw new Response( - "Unauthorized. You are not allowed to view this resource", - { status: 403 } + throw new ShelfStackError( + { + message: "Unauthorized. You are not allowed to view this resource", + status: 403 + } ); const image = await db.image.findUnique({ where: { id: params.imageId }, @@ -17,12 +20,13 @@ export async function loader({ request, params }: LoaderArgs) { /** If the image doesnt belong to the user who has the session. Throw an error. */ if (image?.userId !== session.userId) { - throw new Response("Unauthorized. This resource doesn't belong to you.", { + throw new ShelfStackError({ + message: "Unauthorized. This resource doesn't belong to you.", status: 403, }); } - if (!image) throw new Response("Not found", { status: 404 }); + if (!image) throw new ShelfStackError({ message: "Not found", status: 404 }); return new Response(image.blob, { headers: { diff --git a/app/routes/qr+/$qrId.tsx b/app/routes/qr+/$qrId.tsx index c2c2468fa..ba82f09f8 100644 --- a/app/routes/qr+/$qrId.tsx +++ b/app/routes/qr+/$qrId.tsx @@ -7,6 +7,7 @@ import { getQr } from "~/modules/qr"; import { belongsToCurrentUser } from "~/modules/qr/utils.server"; import { createScan, updateScan } from "~/modules/scan"; import { assertIsPost, notFound } from "~/utils"; +import { ShelfStackError } from "~/utils/error"; export const loader = async ({ request, params }: LoaderArgs) => { /* Get the ID of the QR from the params */ @@ -34,7 +35,7 @@ export const loader = async ({ request, params }: LoaderArgs) => { * that is still there. Will we allow someone to claim it? */ if (!qr) { - throw notFound("Not found"); + throw new ShelfStackError({message:"Not found"}); } /** diff --git a/app/utils/client-hints.tsx b/app/utils/client-hints.tsx index 4d95bae36..05228849d 100644 --- a/app/utils/client-hints.tsx +++ b/app/utils/client-hints.tsx @@ -3,6 +3,7 @@ * are needed by the server, but are only known by the browser. */ import { parseAcceptLanguage } from "intl-parse-accept-language"; +import { ShelfStackError } from "./error"; import { useRequestInfo } from "./request-info"; export const clientHints = { @@ -18,7 +19,7 @@ type ClientHintNames = keyof typeof clientHints; function getCookieValue(cookieString: string, name: ClientHintNames) { const hint = clientHints[name]; if (!hint) { - throw new Error(`Unknown client hint: ${name}`); + throw new ShelfStackError({message:`Unknown client hint: ${name}`}); } const value = cookieString .split(";") diff --git a/app/utils/env.ts b/app/utils/env.ts index 0fd492e83..0c9142301 100644 --- a/app/utils/env.ts +++ b/app/utils/env.ts @@ -1,3 +1,4 @@ +import { ShelfStackError } from "./error"; import { isBrowser } from "./is-browser"; declare global { @@ -42,7 +43,7 @@ function getEnv( const value = source[name as keyof typeof source]; if (!value && isRequired) { - throw new Error(`${name} is not set`); + throw new ShelfStackError({message:`${name} is not set`}); } return value; diff --git a/app/utils/error.ts b/app/utils/error.ts new file mode 100644 index 000000000..d597d7968 --- /dev/null +++ b/app/utils/error.ts @@ -0,0 +1,61 @@ + +import type { HTTPStatusCode } from "./http-status"; + +/** + * The goal of this custom error class is to normalize our errors. + */ + +/** + * @param message The message intended for the user. + * + * Other params are for logging purposes and help us debug. + * @param cause The error that caused the rejection. + * @param metadata Additional data to help us debug. + * @param tag A tag to help us debug and filter logs. + * + */ +export type FailureReason = { + message: string; + title?:string; + status?: HTTPStatusCode; + cause?: unknown; + metadata?: Record; + tag?: string; + traceId?: string; +}; + +/** + * A custom error class to normalize the error handling in our app. + */ +export class ShelfStackError extends Error { + readonly cause: FailureReason["cause"]; + readonly metadata: FailureReason["metadata"]; + readonly tag: FailureReason["tag"]; + readonly status: FailureReason["status"]; + readonly title:FailureReason["title"] + traceId: FailureReason["traceId"]; + + constructor({ + message, + status = 500, + cause = null, + metadata, + tag = "untagged šŸž", + traceId, + title + }: FailureReason) { + super(); + this.name = "ShelfStackError šŸ‘€"; + this.message = message; + this.status = isShelfStackError(cause) ? cause.status : status; + this.cause = cause; + this.metadata = metadata; + this.tag = tag; + this.traceId = traceId + this.title=title + } +} + +export function isShelfStackError(cause: unknown): cause is ShelfStackError { + return cause instanceof ShelfStackError; +} diff --git a/app/utils/http-status.ts b/app/utils/http-status.ts new file mode 100644 index 000000000..0424092d2 --- /dev/null +++ b/app/utils/http-status.ts @@ -0,0 +1,10 @@ +export type HTTPStatusCode = + | 200 + | 204 + | 400 + | 401 + | 403 + | 404 + | 404 + | 405 + | 500; diff --git a/app/utils/http.server.ts b/app/utils/http.server.ts index 14c0b4caf..dfe8187c1 100644 --- a/app/utils/http.server.ts +++ b/app/utils/http.server.ts @@ -1,3 +1,5 @@ +import { ShelfStackError } from "./error"; + export function getCurrentPath(request: Request) { return new URL(request.url).pathname; } @@ -28,15 +30,15 @@ export function isDelete(request: Request) { } export function notFound(message: string) { - return new Response(message, { status: 404 }); + return new ShelfStackError({ message, status: 404 }); } function notAllowedMethod(message: string) { - return new Response(message, { status: 405 }); + return new ShelfStackError({ message, status: 405 }); } function badRequest(message: string) { - return new Response(message, { status: 400 }); + return new ShelfStackError({ message, status: 400 }); } export function getRequiredParam( diff --git a/app/utils/http.test.ts b/app/utils/http.test.ts index 0f645d4c8..446651b27 100644 --- a/app/utils/http.test.ts +++ b/app/utils/http.test.ts @@ -77,7 +77,7 @@ describe(notFound.name, () => { }); it("should return message", async () => { - expect(await notFound("not-found-message").text()).toBe( + expect(await notFound("not-found-message").message).toBe( "not-found-message" ); }); diff --git a/app/utils/storage.server.ts b/app/utils/storage.server.ts index b764fdb24..acc396023 100644 --- a/app/utils/storage.server.ts +++ b/app/utils/storage.server.ts @@ -9,6 +9,7 @@ import { getSupabaseAdmin } from "~/integrations/supabase"; import { requireAuthSession } from "~/modules/auth"; import { cropImage, extractImageNameFromSupabaseUrl } from "."; import { SUPABASE_URL } from "./env"; +import { ShelfStackError } from "./error"; export function getPublicFileURL({ filename, @@ -119,7 +120,7 @@ export async function deleteProfilePicture({ ) || url === "" ) { - throw new Error("Wrong url"); + throw new ShelfStackError({ message: "Wrong url" }); } const { error } = await getSupabaseAdmin() @@ -145,7 +146,7 @@ export async function deleteAssets({ const path = extractImageNameFromSupabaseUrl({ url, bucketName }); if (!path) { - throw new Error("Cannot find image"); + throw new ShelfStackError({message:"Cannot find image"}); } const { error } = await getSupabaseAdmin()