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/trpc/routers/assets.ts | 213 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 packages/trpc/routers/assets.ts (limited to 'packages/trpc/routers/assets.ts') 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( + () => ({}), + ); + }), +}); -- cgit v1.2.3-70-g09d2