aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx209
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx2
-rw-r--r--apps/web/components/dashboard/settings/ImportExport.tsx2
-rw-r--r--apps/web/components/ui/file-picker-button.tsx6
-rw-r--r--packages/shared-react/hooks/bookmarks.ts45
-rw-r--r--packages/shared/types/bookmarks.ts16
-rw-r--r--packages/trpc/lib/attachments.ts42
-rw-r--r--packages/trpc/routers/bookmarks.test.ts97
-rw-r--r--packages/trpc/routers/bookmarks.ts137
9 files changed, 544 insertions, 12 deletions
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx
new file mode 100644
index 00000000..b2460165
--- /dev/null
+++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx
@@ -0,0 +1,209 @@
+import Link from "next/link";
+import { ActionButton } from "@/components/ui/action-button";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import FilePickerButton from "@/components/ui/file-picker-button";
+import { toast } from "@/components/ui/use-toast";
+import useUpload from "@/lib/hooks/upload-file";
+import {
+ Archive,
+ Camera,
+ ChevronsDownUp,
+ Download,
+ Image,
+ Pencil,
+ Plus,
+ Trash2,
+} from "lucide-react";
+
+import {
+ useAttachBookmarkAsset,
+ useDetachBookmarkAsset,
+ useReplaceBookmarkAsset,
+} from "@hoarder/shared-react/hooks/bookmarks";
+import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
+import { ZAssetType, ZBookmark } from "@hoarder/shared/types/bookmarks";
+import {
+ humanFriendlyNameForAssertType,
+ isAllowedToAttachAsset,
+} from "@hoarder/trpc/lib/attachments";
+
+export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
+ const typeToIcon: Record<ZAssetType, React.ReactNode> = {
+ screenshot: <Camera className="size-4" />,
+ fullPageArchive: <Archive className="size-4" />,
+ bannerImage: <Image className="size-4" />,
+ };
+
+ const { mutate: attachAsset, isPending: isAttaching } =
+ useAttachBookmarkAsset({
+ onSuccess: () => {
+ toast({
+ description: "Attachment has been attached!",
+ });
+ },
+ onError: (e) => {
+ toast({
+ description: e.message,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const { mutate: replaceAsset, isPending: isReplacing } =
+ useReplaceBookmarkAsset({
+ onSuccess: () => {
+ toast({
+ description: "Attachment has been replaced!",
+ });
+ },
+ onError: (e) => {
+ toast({
+ description: e.message,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const { mutate: detachAsset, isPending: isDetaching } =
+ useDetachBookmarkAsset({
+ onSuccess: () => {
+ toast({
+ description: "Attachment has been detached!",
+ });
+ },
+ onError: (e) => {
+ toast({
+ description: e.message,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const { mutate: uploadAsset } = useUpload({
+ onError: (e) => {
+ toast({
+ description: e.error,
+ variant: "destructive",
+ });
+ },
+ });
+
+ if (!bookmark.assets.length) {
+ return null;
+ }
+ bookmark.assets.sort((a, b) => a.assetType.localeCompare(b.assetType));
+
+ return (
+ <Collapsible>
+ <CollapsibleTrigger className="flex w-full items-center justify-between gap-2 text-sm text-gray-400">
+ Attachments
+ <ChevronsDownUp className="size-4" />
+ </CollapsibleTrigger>
+ <CollapsibleContent className="flex flex-col gap-1 py-2 text-sm">
+ {bookmark.assets.map((asset) => (
+ <div key={asset.id} className="flex items-center justify-between">
+ <Link
+ target="_blank"
+ href={getAssetUrl(asset.id)}
+ className="flex items-center gap-1"
+ >
+ {typeToIcon[asset.assetType]}
+ <p>{humanFriendlyNameForAssertType(asset.assetType)}</p>
+ </Link>
+ <div className="flex gap-2">
+ <Link
+ title="Download"
+ target="_blank"
+ href={getAssetUrl(asset.id)}
+ className="flex items-center gap-1"
+ download={humanFriendlyNameForAssertType(asset.assetType)}
+ >
+ <Download className="size-4" />
+ </Link>
+ {isAllowedToAttachAsset(asset.assetType) && (
+ <FilePickerButton
+ title="Replace"
+ loading={isReplacing}
+ accept=".jgp,.JPG,.jpeg,.png,.webp"
+ multiple={false}
+ variant="none"
+ size="none"
+ className="flex items-center gap-2"
+ onFileSelect={(file) =>
+ uploadAsset(file, {
+ onSuccess: (resp) => {
+ replaceAsset({
+ bookmarkId: bookmark.id,
+ oldAssetId: asset.id,
+ newAssetId: resp.assetId,
+ });
+ },
+ })
+ }
+ >
+ <Pencil className="size-4" />
+ </FilePickerButton>
+ )}
+ <ActionConfirmingDialog
+ title="Delete Attachment?"
+ description={`Are you sure you want to delete the attachment of the bookmark?`}
+ actionButton={(setDialogOpen) => (
+ <ActionButton
+ loading={isDetaching}
+ variant="destructive"
+ onClick={() =>
+ detachAsset(
+ { bookmarkId: bookmark.id, assetId: asset.id },
+ { onSettled: () => setDialogOpen(false) },
+ )
+ }
+ >
+ <Trash2 className="mr-2 size-4" />
+ Delete
+ </ActionButton>
+ )}
+ >
+ <Button variant="none" size="none" title="Delete">
+ <Trash2 className="size-4" />
+ </Button>
+ </ActionConfirmingDialog>
+ </div>
+ </div>
+ ))}
+ {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") && (
+ <FilePickerButton
+ title="Attach a Banner"
+ loading={isAttaching}
+ accept=".jgp,.JPG,.jpeg,.png,.webp"
+ multiple={false}
+ variant="ghost"
+ size="none"
+ className="flex w-full items-center justify-center gap-2"
+ onFileSelect={(file) =>
+ uploadAsset(file, {
+ onSuccess: (resp) => {
+ attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: resp.assetId,
+ assetType: "bannerImage",
+ },
+ });
+ },
+ })
+ }
+ >
+ <Plus className="size-4" />
+ Attach a Banner
+ </FilePickerButton>
+ )}
+ </CollapsibleContent>
+ </Collapsible>
+ );
+}
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index 13d3c9d8..1f099725 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -25,6 +25,7 @@ import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
import ActionBar from "./ActionBar";
import { AssetContentSection } from "./AssetContentSection";
+import AttachmentBox from "./AttachmentBox";
import { EditableTitle } from "./EditableTitle";
import LinkContentSection from "./LinkContentSection";
import { NoteEditor } from "./NoteEditor";
@@ -153,6 +154,7 @@ export default function BookmarkPreview({
<p className="pt-2 text-sm text-gray-400">Note</p>
<NoteEditor bookmark={bookmark} />
</div>
+ <AttachmentBox bookmark={bookmark} />
<ActionBar bookmark={bookmark} />
</div>
</div>
diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx
index 4827df93..a19db7fd 100644
--- a/apps/web/components/dashboard/settings/ImportExport.tsx
+++ b/apps/web/components/dashboard/settings/ImportExport.tsx
@@ -171,6 +171,7 @@ export function Import() {
<div className="flex flex-col gap-3">
<div className="flex flex-row gap-2">
<FilePickerButton
+ loading={false}
accept=".html"
multiple={false}
className="flex items-center gap-2"
@@ -183,6 +184,7 @@ export function Import() {
</FilePickerButton>
<FilePickerButton
+ loading={false}
accept=".html"
multiple={false}
className="flex items-center gap-2"
diff --git a/apps/web/components/ui/file-picker-button.tsx b/apps/web/components/ui/file-picker-button.tsx
index ccac1643..95e7bbcd 100644
--- a/apps/web/components/ui/file-picker-button.tsx
+++ b/apps/web/components/ui/file-picker-button.tsx
@@ -1,8 +1,8 @@
import React, { ChangeEvent, useRef } from "react";
-import { Button, ButtonProps } from "./button";
+import { ActionButton, ActionButtonProps } from "./action-button";
-interface FilePickerButtonProps extends Omit<ButtonProps, "onClick"> {
+interface FilePickerButtonProps extends Omit<ActionButtonProps, "onClick"> {
onFileSelect?: (file: File) => void;
accept?: string;
multiple?: boolean;
@@ -35,7 +35,7 @@ const FilePickerButton: React.FC<FilePickerButtonProps> = ({
return (
<div>
- <Button onClick={handleButtonClick} {...buttonProps} />
+ <ActionButton onClick={handleButtonClick} {...buttonProps} />
<input
type="file"
ref={fileInputRef}
diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts
index 43f97fc1..cba4d107 100644
--- a/packages/shared-react/hooks/bookmarks.ts
+++ b/packages/shared-react/hooks/bookmarks.ts
@@ -175,3 +175,48 @@ export function useBookmarkPostCreationHook() {
return Promise.all(promises);
};
}
+
+export function useAttachBookmarkAsset(
+ ...opts: Parameters<typeof api.bookmarks.attachAsset.useMutation>
+) {
+ 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<typeof api.bookmarks.replaceAsset.useMutation>
+) {
+ 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<typeof api.bookmarks.detachAsset.useMutation>
+) {
+ 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/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index beefbfb9..c15146f3 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -11,6 +11,18 @@ export const enum BookmarkTypes {
UNKNOWN = "unknown",
}
+export const zAssetTypesSchema = z.enum([
+ "screenshot",
+ "bannerImage",
+ "fullPageArchive",
+]);
+export type ZAssetType = z.infer<typeof zAssetTypesSchema>;
+
+export const zAssetSchema = z.object({
+ id: z.string(),
+ assetType: zAssetTypesSchema,
+});
+
export const zBookmarkedLinkSchema = z.object({
type: z.literal(BookmarkTypes.LINK),
url: z.string().url(),
@@ -63,6 +75,7 @@ export const zBookmarkSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkContentSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmark = z.infer<typeof zBookmarkSchema>;
@@ -71,6 +84,7 @@ const zBookmarkTypeLinkSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkedLinkSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmarkTypeLink = z.infer<typeof zBookmarkTypeLinkSchema>;
@@ -79,6 +93,7 @@ const zBookmarkTypeTextSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkedTextSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmarkTypeText = z.infer<typeof zBookmarkTypeTextSchema>;
@@ -87,6 +102,7 @@ const zBookmarkTypeAssetSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkedAssetSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmarkTypeAsset = z.infer<typeof zBookmarkTypeAssetSchema>;
diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts
new file mode 100644
index 00000000..6fe1ef40
--- /dev/null
+++ b/packages/trpc/lib/attachments.ts
@@ -0,0 +1,42 @@
+import { z } from "zod";
+
+import { AssetTypes } from "@hoarder/db/schema";
+import { ZAssetType, zAssetTypesSchema } from "@hoarder/shared/types/bookmarks";
+
+export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
+ const map: Record<AssetTypes, z.infer<typeof zAssetTypesSchema>> = {
+ [AssetTypes.LINK_SCREENSHOT]: "screenshot",
+ [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive",
+ [AssetTypes.LINK_BANNER_IMAGE]: "bannerImage",
+ };
+ return map[assetType];
+}
+
+export function mapSchemaAssetTypeToDB(
+ assetType: z.infer<typeof zAssetTypesSchema>,
+): AssetTypes {
+ const map: Record<ZAssetType, AssetTypes> = {
+ screenshot: AssetTypes.LINK_SCREENSHOT,
+ fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ bannerImage: AssetTypes.LINK_BANNER_IMAGE,
+ };
+ return map[assetType];
+}
+
+export function humanFriendlyNameForAssertType(type: ZAssetType) {
+ const map: Record<ZAssetType, string> = {
+ screenshot: "Screenshot",
+ fullPageArchive: "Full Page Archive",
+ bannerImage: "Banner Image",
+ };
+ return map[type];
+}
+
+export function isAllowedToAttachAsset(type: ZAssetType) {
+ const map: Record<ZAssetType, boolean> = {
+ screenshot: true,
+ fullPageArchive: false,
+ bannerImage: true,
+ };
+ return map[type];
+}
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index 802bd992..5219e522 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 { bookmarks } from "@hoarder/db/schema";
-import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
+import { assets, AssetTypes, bookmarks } from "@hoarder/db/schema";
+import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks";
import type { CustomTestContext } from "../testUtils";
import { defaultBeforeEach } from "../testUtils";
@@ -326,4 +326,97 @@ describe("Bookmark Routes", () => {
await validateWithLimit(10);
await validateWithLimit(100);
});
+
+ test<CustomTestContext>("mutate assets", async ({ apiCallers, db }) => {
+ const api = apiCallers[0].bookmarks;
+
+ 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,
+ }),
+ db.insert(assets).values({
+ id: "asset2",
+ assetType: AssetTypes.LINK_BANNER_IMAGE,
+ bookmarkId: bookmark.id,
+ }),
+ db.insert(assets).values({
+ id: "asset3",
+ assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ bookmarkId: bookmark.id,
+ }),
+ ]);
+
+ 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: "asset4",
+ }),
+ ).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 eb189def..312c3acc 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -31,6 +31,7 @@ import { getSearchIdxClient } from "@hoarder/shared/search";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
+ zAssetSchema,
zBareBookmarkSchema,
zBookmarkSchema,
zGetBookmarksRequestSchema,
@@ -41,6 +42,11 @@ import {
import type { AuthedContext, Context } from "../index";
import { authedProcedure, router } from "../index";
+import {
+ isAllowedToAttachAsset,
+ mapDBAssetTypeToUserType,
+ mapSchemaAssetTypeToDB,
+} from "../lib/attachments";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
ctx: Context;
@@ -79,13 +85,12 @@ interface Asset {
assetType: AssetTypes;
}
-const ASSET_TYE_MAPPING: Record<AssetTypes, string> = {
- [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId",
- [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId",
- [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId",
-};
-
function mapAssetsToBookmarkFields(assets: Asset | Asset[] = []) {
+ const ASSET_TYE_MAPPING: Record<AssetTypes, string> = {
+ [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<string, string>, asset: Asset) => {
result[ASSET_TYE_MAPPING[asset.assetType]] = asset.id;
@@ -208,6 +213,10 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark {
...t.tag,
})),
content,
+ assets: assets.map((a) => ({
+ id: a.id,
+ assetType: mapDBAssetTypeToUserType(a.assetType),
+ })),
...rest,
};
}
@@ -301,6 +310,7 @@ export const bookmarksAppRouter = router({
return {
alreadyExists: false,
tags: [] as ZBookmarkTags[],
+ assets: [],
content,
...bookmark,
};
@@ -599,6 +609,7 @@ export const bookmarksAppRouter = router({
...row.bookmarksSq,
content,
tags: [],
+ assets: [],
};
}
@@ -617,11 +628,18 @@ export const bookmarksAppRouter = router({
});
}
- if (row.assets) {
+ if (
+ row.assets &&
+ !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id)
+ ) {
acc[bookmarkId].content = {
...acc[bookmarkId].content,
...mapAssetsToBookmarkFields(row.assets),
};
+ acc[bookmarkId].assets.push({
+ id: row.assets.id,
+ assetType: mapDBAssetTypeToUserType(row.assets.assetType),
+ });
}
return acc;
@@ -787,4 +805,109 @@ export const bookmarksAppRouter = router({
};
});
}),
+
+ attachAsset: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ asset: zAssetSchema,
+ }),
+ )
+ .output(zAssetSchema)
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ if (!isAllowedToAttachAsset(input.asset.assetType)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't attach this type of asset",
+ });
+ }
+ await ctx.db
+ .insert(assets)
+ .values({
+ id: input.asset.id,
+ assetType: mapSchemaAssetTypeToDB(input.asset.assetType),
+ bookmarkId: input.bookmarkId,
+ })
+ .returning();
+ 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 }) => {
+ const oldAsset = await ctx.db
+ .select()
+ .from(assets)
+ .where(
+ and(
+ eq(assets.id, input.oldAssetId),
+ eq(assets.bookmarkId, input.bookmarkId),
+ ),
+ )
+ .limit(1);
+ if (!oldAsset.length) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ if (
+ !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset[0].assetType))
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't attach this type of asset",
+ });
+ }
+
+ 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 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 }) => {
+ 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(
+ () => ({}),
+ );
+ }),
});