From 1e5c575e16c8a9e6bd7592e83bea53af7f359e15 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Sun, 6 Oct 2024 14:33:40 +0000 Subject: refactor: Start tracking bookmark assets in the assets table --- packages/trpc/lib/attachments.ts | 19 ++++ packages/trpc/routers/bookmarks.test.ts | 20 +++- packages/trpc/routers/bookmarks.ts | 156 +++++++++++++++++++++----------- packages/trpc/testUtils.ts | 5 +- 4 files changed, 146 insertions(+), 54 deletions(-) (limited to 'packages/trpc') diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts index 6fe1ef40..175947f8 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -8,6 +8,8 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType { [AssetTypes.LINK_SCREENSHOT]: "screenshot", [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive", [AssetTypes.LINK_BANNER_IMAGE]: "bannerImage", + [AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset", + [AssetTypes.UNKNOWN]: "bannerImage", }; return map[assetType]; } @@ -19,6 +21,8 @@ export function mapSchemaAssetTypeToDB( screenshot: AssetTypes.LINK_SCREENSHOT, fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE, bannerImage: AssetTypes.LINK_BANNER_IMAGE, + bookmarkAsset: AssetTypes.BOOKMARK_ASSET, + unknown: AssetTypes.UNKNOWN, }; return map[assetType]; } @@ -28,6 +32,8 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) { screenshot: "Screenshot", fullPageArchive: "Full Page Archive", bannerImage: "Banner Image", + bookmarkAsset: "Bookmark Asset", + unknown: "Unknown", }; return map[type]; } @@ -37,6 +43,19 @@ export function isAllowedToAttachAsset(type: ZAssetType) { screenshot: true, fullPageArchive: false, bannerImage: true, + bookmarkAsset: false, + unknown: false, + }; + return map[type]; +} + +export function isAllowedToDetachAsset(type: ZAssetType) { + const map: Record = { + screenshot: true, + fullPageArchive: true, + bannerImage: true, + bookmarkAsset: false, + unknown: false, }; return map[type]; } diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index d6a7bc27..d2944c40 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -369,6 +369,24 @@ describe("Bookmark Routes", () => { bookmarkId: bookmark.id, userId, }), + db.insert(assets).values({ + id: "asset4", + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId, + }), + db.insert(assets).values({ + id: "asset5", + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId, + }), + db.insert(assets).values({ + id: "asset6", + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId, + }), ]); const validateAssets = async ( @@ -424,7 +442,7 @@ describe("Bookmark Routes", () => { await api.replaceAsset({ bookmarkId: bookmark.id, oldAssetId: "asset3", - newAssetId: "asset4", + newAssetId: "asset6", }), ).rejects.toThrow(/You can't attach this type of asset/); await expect( diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index b1491a61..f272433a 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -44,6 +44,7 @@ import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; import { isAllowedToAttachAsset, + isAllowedToDetachAsset, mapDBAssetTypeToUserType, mapSchemaAssetTypeToDB, } from "../lib/attachments"; @@ -80,23 +81,35 @@ export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ return opts.next(); }); -interface Asset { - id: string; - assetType: AssetTypes; -} - -function mapAssetsToBookmarkFields(assets: Asset | Asset[] = []) { - const ASSET_TYE_MAPPING: Record = { - [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId", - [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId", - [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId", - }; - const assetsArray = Array.isArray(assets) ? assets : [assets]; - return assetsArray.reduce((result: Record, asset: Asset) => { - result[ASSET_TYE_MAPPING[asset.assetType]] = asset.id; - return result; - }, {}); -} +export const ensureAssetOwnership = async (opts: { + ctx: Context; + assetId: string; +}) => { + const asset = await opts.ctx.db.query.assets.findFirst({ + where: eq(bookmarks.id, opts.assetId), + columns: { + userId: true, + }, + }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } + if (!asset) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Asset not found", + }); + } + if (asset.userId != opts.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } +}; async function getBookmark(ctx: AuthedContext, bookmarkId: string) { const bookmark = await ctx.db.query.bookmarks.findFirst({ @@ -189,7 +202,15 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark { case BookmarkTypes.LINK: content = { type: bookmark.type, - ...mapAssetsToBookmarkFields(assets), + screenshotAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_SCREENSHOT, + )?.id, + fullPageArchiveAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE, + )?.id, + imageAssetId: assets.find( + (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE, + )?.id, ...link, }; break; @@ -307,6 +328,19 @@ export const bookmarksAppRouter = router({ sourceUrl: null, }) .returning(); + await ensureAssetOwnership({ ctx, assetId: input.assetId }); + await tx + .update(assets) + .set({ + bookmarkId: bookmark.id, + assetType: AssetTypes.BOOKMARK_ASSET, + }) + .where( + and( + eq(assets.id, input.assetId), + eq(assets.userId, ctx.user.id), + ), + ); content = { type: BookmarkTypes.ASSET, assetType: asset.assetType, @@ -647,10 +681,20 @@ export const bookmarksAppRouter = router({ row.assets && !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id) ) { - acc[bookmarkId].content = { - ...acc[bookmarkId].content, - ...mapAssetsToBookmarkFields(row.assets), - }; + if (acc[bookmarkId].content.type == BookmarkTypes.LINK) { + const content = acc[bookmarkId].content; + invariant(content.type == BookmarkTypes.LINK); + if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) { + content.screenshotAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) { + content.fullPageArchiveAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_BANNER_IMAGE) { + content.imageAssetId = row.assets.id; + } + acc[bookmarkId].content = content; + } acc[bookmarkId].assets.push({ id: row.assets.id, assetType: mapDBAssetTypeToUserType(row.assets.assetType), @@ -841,6 +885,7 @@ export const bookmarksAppRouter = router({ .output(zAssetSchema) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { + await ensureAssetOwnership({ ctx, assetId: input.asset.id }); if (!isAllowedToAttachAsset(input.asset.assetType)) { throw new TRPCError({ code: "BAD_REQUEST", @@ -848,14 +893,14 @@ export const bookmarksAppRouter = router({ }); } await ctx.db - .insert(assets) - .values({ - id: input.asset.id, + .update(assets) + .set({ assetType: mapSchemaAssetTypeToDB(input.asset.assetType), bookmarkId: input.bookmarkId, - userId: ctx.user.id, }) - .returning(); + .where( + and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)), + ); return input.asset; }), replaceAsset: authedProcedure @@ -869,21 +914,19 @@ export const bookmarksAppRouter = router({ .output(z.void()) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - const oldAsset = await ctx.db + await Promise.all([ + ensureAssetOwnership({ ctx, assetId: input.oldAssetId }), + ensureAssetOwnership({ ctx, assetId: input.newAssetId }), + ]); + const [oldAsset] = await ctx.db .select() .from(assets) .where( - and( - eq(assets.id, input.oldAssetId), - eq(assets.bookmarkId, input.bookmarkId), - ), + and(eq(assets.id, input.oldAssetId), eq(assets.userId, ctx.user.id)), ) .limit(1); - if (!oldAsset.length) { - throw new TRPCError({ code: "NOT_FOUND" }); - } if ( - !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset[0].assetType)) + !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) ) { throw new TRPCError({ code: "BAD_REQUEST", @@ -891,21 +934,17 @@ export const bookmarksAppRouter = router({ }); } - const result = await ctx.db - .update(assets) - .set({ - id: input.newAssetId, - bookmarkId: input.bookmarkId, - }) - .where( - and( - eq(assets.id, input.oldAssetId), - eq(assets.bookmarkId, input.bookmarkId), - ), - ); - if (result.changes == 0) { - throw new TRPCError({ code: "NOT_FOUND" }); - } + await ctx.db.transaction(async (tx) => { + await tx.delete(assets).where(eq(assets.id, input.oldAssetId)); + await tx + .update(assets) + .set({ + bookmarkId: input.bookmarkId, + assetType: oldAsset.assetType, + }) + .where(eq(assets.id, input.newAssetId)); + }); + await deleteAsset({ userId: ctx.user.id, assetId: input.oldAssetId, @@ -921,6 +960,21 @@ export const bookmarksAppRouter = router({ .output(z.void()) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { + await ensureAssetOwnership({ ctx, assetId: input.assetId }); + const [oldAsset] = await ctx.db + .select() + .from(assets) + .where( + and(eq(assets.id, input.assetId), eq(assets.userId, ctx.user.id)), + ); + if ( + !isAllowedToDetachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't deattach this type of asset", + }); + } const result = await ctx.db .delete(assets) .where( diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts index 67fbddcc..04e6b0a3 100644 --- a/packages/trpc/testUtils.ts +++ b/packages/trpc/testUtils.ts @@ -26,12 +26,13 @@ export async function seedUsers(db: TestDB) { .returning(); } -export function getApiCaller(db: TestDB, userId?: string) { +export function getApiCaller(db: TestDB, userId?: string, email?: string) { const createCaller = createCallerFactory(appRouter); return createCaller({ user: userId ? { id: userId, + email, role: "user", } : null, @@ -55,7 +56,7 @@ export async function buildTestContext( if (seedDB) { users = await seedUsers(db); } - const callers = users.map((u) => getApiCaller(db, u.id)); + const callers = users.map((u) => getApiCaller(db, u.id, u.email)); return { apiCallers: callers, -- cgit v1.2.3-70-g09d2