From 14e4fed321634dc014ad2f15cafef3ed0123855e Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 23 Feb 2025 22:50:12 +0000 Subject: feat: Add a setting page to manage assets. Fixes #730 --- packages/shared-react/hooks/assets.ts | 49 +++++++ packages/shared-react/hooks/bookmarks.ts | 45 ------- packages/trpc/lib/attachments.ts | 2 +- packages/trpc/routers/_app.ts | 2 + packages/trpc/routers/assets.test.ts | 128 +++++++++++++++++++ packages/trpc/routers/assets.ts | 213 +++++++++++++++++++++++++++++++ packages/trpc/routers/bookmarks.test.ts | 119 +---------------- packages/trpc/routers/bookmarks.ts | 153 +--------------------- 8 files changed, 397 insertions(+), 314 deletions(-) create mode 100644 packages/shared-react/hooks/assets.ts create mode 100644 packages/trpc/routers/assets.test.ts create mode 100644 packages/trpc/routers/assets.ts (limited to 'packages') diff --git a/packages/shared-react/hooks/assets.ts b/packages/shared-react/hooks/assets.ts new file mode 100644 index 00000000..b9aeed26 --- /dev/null +++ b/packages/shared-react/hooks/assets.ts @@ -0,0 +1,49 @@ +import { api } from "../trpc"; + +export function useAttachBookmarkAsset( + ...opts: Parameters +) { + const apiUtils = api.useUtils(); + return api.assets.attachAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + apiUtils.assets.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useReplaceBookmarkAsset( + ...opts: Parameters +) { + const apiUtils = api.useUtils(); + return api.assets.replaceAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + apiUtils.assets.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useDetachBookmarkAsset( + ...opts: Parameters +) { + const apiUtils = api.useUtils(); + return api.assets.detachAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + apiUtils.assets.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts index f4dd203c..89715e4f 100644 --- a/packages/shared-react/hooks/bookmarks.ts +++ b/packages/shared-react/hooks/bookmarks.ts @@ -190,48 +190,3 @@ export function useBookmarkPostCreationHook() { return Promise.all(promises); }; } - -export function useAttachBookmarkAsset( - ...opts: Parameters -) { - const apiUtils = api.useUtils(); - return api.bookmarks.attachAsset.useMutation({ - ...opts[0], - onSuccess: (res, req, meta) => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.searchBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); - return opts[0]?.onSuccess?.(res, req, meta); - }, - }); -} - -export function useReplaceBookmarkAsset( - ...opts: Parameters -) { - const apiUtils = api.useUtils(); - return api.bookmarks.replaceAsset.useMutation({ - ...opts[0], - onSuccess: (res, req, meta) => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.searchBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); - return opts[0]?.onSuccess?.(res, req, meta); - }, - }); -} - -export function useDetachBookmarkAsset( - ...opts: Parameters -) { - const apiUtils = api.useUtils(); - return api.bookmarks.detachAsset.useMutation({ - ...opts[0], - onSuccess: (res, req, meta) => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.searchBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); - return opts[0]?.onSuccess?.(res, req, meta); - }, - }); -} diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts index 3ad79a5a..29c99172 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -66,7 +66,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) { screenshot: true, assetScreenshot: true, fullPageArchive: true, - precrawledArchive: false, + precrawledArchive: true, bannerImage: true, video: true, bookmarkAsset: false, diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 0d555a65..7af19884 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -1,6 +1,7 @@ import { router } from "../index"; import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; +import { assetsAppRouter } from "./assets"; import { bookmarksAppRouter } from "./bookmarks"; import { feedsAppRouter } from "./feeds"; import { highlightsAppRouter } from "./highlights"; @@ -21,6 +22,7 @@ export const appRouter = router({ feeds: feedsAppRouter, highlights: highlightsAppRouter, webhooks: webhooksAppRouter, + assets: assetsAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/assets.test.ts b/packages/trpc/routers/assets.test.ts new file mode 100644 index 00000000..d7db35be --- /dev/null +++ b/packages/trpc/routers/assets.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { assets, AssetTypes } from "@hoarder/db/schema"; +import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach(defaultBeforeEach(true)); + +describe("Asset Routes", () => { + test("mutate assets", async ({ apiCallers, db }) => { + const api = apiCallers[0].assets; + const userId = await apiCallers[0].users.whoami().then((u) => u.id); + + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://google.com", + type: BookmarkTypes.LINK, + }); + await Promise.all([ + db.insert(assets).values({ + id: "asset1", + assetType: AssetTypes.LINK_SCREENSHOT, + bookmarkId: bookmark.id, + userId, + }), + db.insert(assets).values({ + id: "asset2", + assetType: AssetTypes.LINK_BANNER_IMAGE, + bookmarkId: bookmark.id, + userId, + }), + db.insert(assets).values({ + id: "asset3", + assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, + 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 ( + expected: { id: string; assetType: ZAssetType }[], + ) => { + const b = await apiCallers[0].bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }); + b.assets.sort((a, b) => a.id.localeCompare(b.id)); + expect(b.assets).toEqual(expected); + }; + + await api.attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: "asset4", + assetType: "screenshot", + }, + }); + + await validateAssets([ + { id: "asset1", assetType: "screenshot" }, + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset4", assetType: "screenshot" }, + ]); + + await api.replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: "asset1", + newAssetId: "asset5", + }); + + await validateAssets([ + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset4", assetType: "screenshot" }, + { id: "asset5", assetType: "screenshot" }, + ]); + + await api.detachAsset({ + bookmarkId: bookmark.id, + assetId: "asset4", + }); + + await validateAssets([ + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset5", assetType: "screenshot" }, + ]); + + // You're not allowed to attach/replace a fullPageArchive + await expect( + async () => + await api.replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: "asset3", + newAssetId: "asset6", + }), + ).rejects.toThrow(/You can't attach this type of asset/); + await expect( + async () => + await api.attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: "asset6", + assetType: "fullPageArchive", + }, + }), + ).rejects.toThrow(/You can't attach this type of asset/); + }); +}); diff --git a/packages/trpc/routers/assets.ts b/packages/trpc/routers/assets.ts new file mode 100644 index 00000000..45eac068 --- /dev/null +++ b/packages/trpc/routers/assets.ts @@ -0,0 +1,213 @@ +import { TRPCError } from "@trpc/server"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { assets, bookmarks } from "@hoarder/db/schema"; +import { deleteAsset } from "@hoarder/shared/assetdb"; +import { + zAssetSchema, + zAssetTypesSchema, +} from "@hoarder/shared/types/bookmarks"; + +import { authedProcedure, Context, router } from "../index"; +import { + isAllowedToAttachAsset, + isAllowedToDetachAsset, + mapDBAssetTypeToUserType, + mapSchemaAssetTypeToDB, +} from "../lib/attachments"; +import { ensureBookmarkOwnership } from "./bookmarks"; + +export const ensureAssetOwnership = async (opts: { + ctx: Context; + assetId: string; +}) => { + const asset = await opts.ctx.db.query.assets.findFirst({ + where: eq(bookmarks.id, opts.assetId), + }); + 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", + }); + } + return asset; +}; + +export const assetsAppRouter = router({ + list: authedProcedure + .input( + z.object({ + limit: z.number().min(1).max(100).default(20), + cursor: z.number().nullish(), // page number + }), + ) + .output( + z.object({ + assets: z.array( + z.object({ + id: z.string(), + assetType: zAssetTypesSchema, + size: z.number(), + contentType: z.string().nullable(), + fileName: z.string().nullable(), + bookmarkId: z.string().nullable(), + }), + ), + nextCursor: z.number().nullish(), + totalCount: z.number(), + }), + ) + .query(async ({ input, ctx }) => { + const page = input.cursor ?? 1; + const [results, totalCount] = await Promise.all([ + ctx.db + .select() + .from(assets) + .where(eq(assets.userId, ctx.user.id)) + .orderBy(desc(assets.size)) + .limit(input.limit) + .offset((page - 1) * input.limit), + ctx.db + .select({ count: sql`count(*)` }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)), + ]); + + return { + assets: results.map((a) => ({ + ...a, + assetType: mapDBAssetTypeToUserType(a.assetType), + })), + nextCursor: page * input.limit < totalCount[0].count ? page + 1 : null, + totalCount: totalCount[0].count, + }; + }), + attachAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + asset: zAssetSchema, + }), + ) + .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", + message: "You can't attach this type of asset", + }); + } + await ctx.db + .update(assets) + .set({ + assetType: mapSchemaAssetTypeToDB(input.asset.assetType), + bookmarkId: input.bookmarkId, + }) + .where( + and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)), + ); + return input.asset; + }), + replaceAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + oldAssetId: z.string(), + newAssetId: z.string(), + }), + ) + .output(z.void()) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + 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.userId, ctx.user.id)), + ) + .limit(1); + if ( + !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't attach this type of asset", + }); + } + + 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, + }).catch(() => ({})); + }), + detachAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + assetId: z.string(), + }), + ) + .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( + and( + eq(assets.id, input.assetId), + eq(assets.bookmarkId, input.bookmarkId), + ), + ); + if (result.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch( + () => ({}), + ); + }), +}); diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index d2944c40..d89f80fd 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -1,7 +1,7 @@ import { assert, beforeEach, describe, expect, test } from "vitest"; -import { assets, AssetTypes, bookmarks } from "@hoarder/db/schema"; -import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks"; +import { bookmarks } from "@hoarder/db/schema"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import type { CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; @@ -341,119 +341,4 @@ describe("Bookmark Routes", () => { await validateWithLimit(10); await validateWithLimit(100); }); - - test("mutate assets", async ({ apiCallers, db }) => { - const api = apiCallers[0].bookmarks; - const userId = await apiCallers[0].users.whoami().then((u) => u.id); - - const bookmark = await api.createBookmark({ - url: "https://google.com", - type: BookmarkTypes.LINK, - }); - await Promise.all([ - db.insert(assets).values({ - id: "asset1", - assetType: AssetTypes.LINK_SCREENSHOT, - bookmarkId: bookmark.id, - userId, - }), - db.insert(assets).values({ - id: "asset2", - assetType: AssetTypes.LINK_BANNER_IMAGE, - bookmarkId: bookmark.id, - userId, - }), - db.insert(assets).values({ - id: "asset3", - assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, - 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 ( - expected: { id: string; assetType: ZAssetType }[], - ) => { - const b = await api.getBookmark({ bookmarkId: bookmark.id }); - b.assets.sort((a, b) => a.id.localeCompare(b.id)); - expect(b.assets).toEqual(expected); - }; - - await api.attachAsset({ - bookmarkId: bookmark.id, - asset: { - id: "asset4", - assetType: "screenshot", - }, - }); - - await validateAssets([ - { id: "asset1", assetType: "screenshot" }, - { id: "asset2", assetType: "bannerImage" }, - { id: "asset3", assetType: "fullPageArchive" }, - { id: "asset4", assetType: "screenshot" }, - ]); - - await api.replaceAsset({ - bookmarkId: bookmark.id, - oldAssetId: "asset1", - newAssetId: "asset5", - }); - - await validateAssets([ - { id: "asset2", assetType: "bannerImage" }, - { id: "asset3", assetType: "fullPageArchive" }, - { id: "asset4", assetType: "screenshot" }, - { id: "asset5", assetType: "screenshot" }, - ]); - - await api.detachAsset({ - bookmarkId: bookmark.id, - assetId: "asset4", - }); - - await validateAssets([ - { id: "asset2", assetType: "bannerImage" }, - { id: "asset3", assetType: "fullPageArchive" }, - { id: "asset5", assetType: "screenshot" }, - ]); - - // You're not allowed to attach/replace a fullPageArchive - await expect( - async () => - await api.replaceAsset({ - bookmarkId: bookmark.id, - oldAssetId: "asset3", - newAssetId: "asset6", - }), - ).rejects.toThrow(/You can't attach this type of asset/); - await expect( - async () => - await api.attachAsset({ - bookmarkId: bookmark.id, - asset: { - id: "asset6", - assetType: "fullPageArchive", - }, - }), - ).rejects.toThrow(/You can't attach this type of asset/); - }); }); diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 6ab863fb..3b2d23ce 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -55,7 +55,6 @@ import { parseSearchQuery } from "@hoarder/shared/searchQueryParser"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, - zAssetSchema, zBareBookmarkSchema, zBookmarkSchema, zGetBookmarksRequestSchema, @@ -69,13 +68,9 @@ import { import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; -import { - isAllowedToAttachAsset, - isAllowedToDetachAsset, - mapDBAssetTypeToUserType, - mapSchemaAssetTypeToDB, -} from "../lib/attachments"; +import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; +import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ ctx: Context; @@ -109,34 +104,6 @@ export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ return opts.next(); }); -export const ensureAssetOwnership = async (opts: { - ctx: Context; - assetId: string; -}) => { - const asset = await opts.ctx.db.query.assets.findFirst({ - where: eq(bookmarks.id, opts.assetId), - }); - 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", - }); - } - return asset; -}; - async function getBookmark(ctx: AuthedContext, bookmarkId: string) { const bookmark = await ctx.db.query.bookmarks.findFirst({ where: and(eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, bookmarkId)), @@ -1060,122 +1027,6 @@ export const bookmarksAppRouter = router({ }; }); }), - - attachAsset: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - asset: zAssetSchema, - }), - ) - .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", - message: "You can't attach this type of asset", - }); - } - await ctx.db - .update(assets) - .set({ - assetType: mapSchemaAssetTypeToDB(input.asset.assetType), - bookmarkId: input.bookmarkId, - }) - .where( - and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)), - ); - return input.asset; - }), - replaceAsset: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - oldAssetId: z.string(), - newAssetId: z.string(), - }), - ) - .output(z.void()) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - 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.userId, ctx.user.id)), - ) - .limit(1); - if ( - !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You can't attach this type of asset", - }); - } - - 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, - }).catch(() => ({})); - }), - detachAsset: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - assetId: z.string(), - }), - ) - .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( - and( - eq(assets.id, input.assetId), - eq(assets.bookmarkId, input.bookmarkId), - ), - ); - if (result.changes == 0) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch( - () => ({}), - ); - }), getBrokenLinks: authedProcedure .output( z.object({ -- cgit v1.2.3-70-g09d2