diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts index f3cf1ab4..73237d8d 100644 --- a/apps/web/app/api/assets/[assetId]/route.ts +++ b/apps/web/app/api/assets/[assetId]/route.ts @@ -1,5 +1,7 @@ import { createContextFromRequest } from "@/server/api/client"; +import { and, eq } from "drizzle-orm"; +import { assets } from "@hoarder/db/schema"; import { readAsset } from "@hoarder/shared/assetdb"; export const dynamic = "force-dynamic"; @@ -11,6 +13,15 @@ export async function GET( if (!ctx.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + + const assetDb = await ctx.db.query.assets.findFirst({ + where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), + }); + + if (!assetDb) { + return Response.json({ error: "Asset not found" }, { status: 404 }); + } + const { asset, metadata } = await readAsset({ userId: ctx.user.id, assetId: params.assetId, diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts index 9028f556..0e52ff93 100644 --- a/apps/web/app/api/assets/route.ts +++ b/apps/web/app/api/assets/route.ts @@ -2,6 +2,7 @@ import { createContextFromRequest } from "@/server/api/client"; import { TRPCError } from "@trpc/server"; import type { ZUploadResponse } from "@hoarder/shared/types/uploads"; +import { assets, AssetTypes } from "@hoarder/db/schema"; import { newAssetId, saveAsset, @@ -43,8 +44,22 @@ export async function POST(request: Request) { return Response.json({ error: "Bad request" }, { status: 400 }); } - const assetId = newAssetId(); const fileName = data.name; + const [assetDb] = await ctx.db + .insert(assets) + .values({ + id: newAssetId(), + // Initially, uploads are uploaded for unknown purpose + // And without an attached bookmark. + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId: ctx.user.id, + contentType, + size: data.size, + fileName, + }) + .returning(); + const assetId = assetDb.id; await saveAsset({ userId: ctx.user.id, diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index a8eaf0f4..436f1026 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -16,6 +16,7 @@ import { ChevronsDownUp, Download, Image, + Paperclip, Pencil, Plus, Trash2, @@ -35,6 +36,7 @@ import { import { humanFriendlyNameForAssertType, isAllowedToAttachAsset, + isAllowedToDetachAsset, } from "@hoarder/trpc/lib/attachments"; export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { @@ -42,6 +44,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { screenshot: , fullPageArchive: , bannerImage: , + bookmarkAsset: , + unknown: , }; const { mutate: attachAsset, isPending: isAttaching } = @@ -100,11 +104,6 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { bookmark.assets.sort((a, b) => a.assetType.localeCompare(b.assetType)); - if (bookmark.content.type == BookmarkTypes.ASSET) { - // Currently, we don't allow attaching assets to assets types. - return null; - } - return ( @@ -156,59 +155,62 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { )} - ( - - detachAsset( - { bookmarkId: bookmark.id, assetId: asset.id }, - { onSettled: () => setDialogOpen(false) }, - ) - } - > - - Delete - - )} - > - - + {isAllowedToDetachAsset(asset.assetType) && ( + ( + + detachAsset( + { bookmarkId: bookmark.id, assetId: asset.id }, + { onSettled: () => setDialogOpen(false) }, + ) + } + > + + Delete + + )} + > + + + )} ))} - {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") && ( - - uploadAsset(file, { - onSuccess: (resp) => { - attachAsset({ - bookmarkId: bookmark.id, - asset: { - id: resp.assetId, - assetType: "bannerImage", - }, - }); - }, - }) - } - > - - Attach a Banner - - )} + {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") && + bookmark.content.type != BookmarkTypes.ASSET && ( + + uploadAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }) + } + > + + Attach a Banner + + )} ); diff --git a/apps/workers/crawlerWorker.ts b/apps/workers/crawlerWorker.ts index f830c500..74413c63 100644 --- a/apps/workers/crawlerWorker.ts +++ b/apps/workers/crawlerWorker.ts @@ -36,6 +36,7 @@ import { DequeuedJob, Runner } from "@hoarder/queue"; import { ASSET_TYPES, deleteAsset, + getAssetSize, IMAGE_ASSET_TYPES, newAssetId, saveAsset, @@ -192,6 +193,8 @@ export class CrawlerWorker { } } +type DBAssetType = typeof assets.$inferInsert; + async function changeBookmarkStatus( bookmarkId: string, crawlStatus: "success" | "failure", @@ -353,16 +356,18 @@ async function storeScreenshot( return null; } const assetId = newAssetId(); + const contentType = "image/png"; + const fileName = "screenshot.png"; await saveAsset({ userId, assetId, - metadata: { contentType: "image/png", fileName: "screenshot.png" }, + metadata: { contentType, fileName }, asset: screenshot, }); logger.info( `[Crawler][${jobId}] Stored the screenshot as assetId: ${assetId}`, ); - return assetId; + return { assetId, contentType, fileName, size: screenshot.byteLength }; } async function downloadAndStoreFile( @@ -396,7 +401,7 @@ async function downloadAndStoreFile( `[Crawler][${jobId}] Downloaded ${fileType} as assetId: ${assetId}`, ); - return assetId; + return { assetId, userId, contentType, size: buffer.byteLength }; } catch (e) { logger.error( `[Crawler][${jobId}] Failed to download and store ${fileType}: ${e}`, @@ -433,12 +438,14 @@ async function archiveWebpage( input: html, })`monolith - -Ije -t 5 -b ${url} -o ${assetPath}`; + const contentType = "text/html"; + await saveAssetFromFile({ userId, assetId, assetPath, metadata: { - contentType: "text/html", + contentType, }, }); @@ -446,7 +453,11 @@ async function archiveWebpage( `[Crawler][${jobId}] Done archiving the page as assetId: ${assetId}`, ); - return assetId; + return { + assetId, + contentType, + size: await getAssetSize({ userId, assetId }), + }; } async function getContentType( @@ -489,17 +500,31 @@ async function handleAsAssetBookmark( jobId: string, bookmarkId: string, ) { - const assetId = await downloadAndStoreFile(url, userId, jobId, assetType); - if (!assetId) { + const downloaded = await downloadAndStoreFile(url, userId, jobId, assetType); + if (!downloaded) { return; } + const fileName = path.basename(new URL(url).pathname); await db.transaction(async (trx) => { + await updateAsset( + undefined, + { + id: downloaded.assetId, + bookmarkId, + userId, + assetType: AssetTypes.BOOKMARK_ASSET, + contentType: downloaded.contentType, + size: downloaded.size, + fileName, + }, + trx, + ); await trx.insert(bookmarkAssets).values({ id: bookmarkId, assetType, - assetId, + assetId: downloaded.assetId, content: null, - fileName: path.basename(new URL(url).pathname), + fileName, sourceUrl: url, }); // Switch the type of the bookmark from LINK to ASSET @@ -527,14 +552,24 @@ async function crawlAndParseUrl( url: browserUrl, } = await crawlPage(jobId, url); - const [meta, readableContent, screenshotAssetId] = await Promise.all([ + const [meta, readableContent, screenshotAssetInfo] = await Promise.all([ extractMetadata(htmlContent, browserUrl, jobId), extractReadableContent(htmlContent, browserUrl, jobId), storeScreenshot(screenshot, userId, jobId), ]); - let imageAssetId: string | null = null; + let imageAssetInfo: DBAssetType | null = null; if (meta.image) { - imageAssetId = await downloadAndStoreImage(meta.image, userId, jobId); + const downloaded = await downloadAndStoreImage(meta.image, userId, jobId); + if (downloaded) { + imageAssetInfo = { + id: downloaded.assetId, + bookmarkId, + userId, + assetType: AssetTypes.LINK_BANNER_IMAGE, + contentType: downloaded.contentType, + size: downloaded.size, + }; + } } // TODO(important): Restrict the size of content to store @@ -552,22 +587,24 @@ async function crawlAndParseUrl( }) .where(eq(bookmarkLinks.id, bookmarkId)); - await updateAsset( - screenshotAssetId, - oldScreenshotAssetId, - bookmarkId, - userId, - AssetTypes.LINK_SCREENSHOT, - txn, - ); - await updateAsset( - imageAssetId, - oldImageAssetId, - bookmarkId, - userId, - AssetTypes.LINK_BANNER_IMAGE, - txn, - ); + if (screenshotAssetInfo) { + await updateAsset( + oldScreenshotAssetId, + { + id: screenshotAssetInfo.assetId, + bookmarkId, + userId, + assetType: AssetTypes.LINK_SCREENSHOT, + contentType: screenshotAssetInfo.contentType, + size: screenshotAssetInfo.size, + fileName: screenshotAssetInfo.fileName, + }, + txn, + ); + } + if (imageAssetInfo) { + await updateAsset(oldImageAssetId, imageAssetInfo, txn); + } }); // Delete the old assets if any @@ -582,20 +619,24 @@ async function crawlAndParseUrl( return async () => { if (serverConfig.crawler.fullPageArchive || archiveFullPage) { - const fullPageArchiveAssetId = await archiveWebpage( - htmlContent, - browserUrl, - userId, - jobId, - ); + const { + assetId: fullPageArchiveAssetId, + size, + contentType, + } = await archiveWebpage(htmlContent, browserUrl, userId, jobId); await db.transaction(async (txn) => { await updateAsset( - fullPageArchiveAssetId, oldFullPageArchiveAssetId, - bookmarkId, - userId, - AssetTypes.LINK_FULL_PAGE_ARCHIVE, + { + id: fullPageArchiveAssetId, + bookmarkId, + userId, + assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, + contentType, + size, + fileName: null, + }, txn, ); }); @@ -676,31 +717,13 @@ async function runCrawler(job: DequeuedJob) { await archivalLogic(); } -/** - * Removes the old asset and adds a new one instead - * @param newAssetId the new assetId to add - * @param oldAssetId the old assetId to remove (if it exists) - * @param bookmarkId the id of the bookmark the asset belongs to - * @param assetType the type of the asset - * @param txn the transaction where this update should happen in - */ async function updateAsset( - newAssetId: string | null, oldAssetId: string | undefined, - bookmarkId: string, - userId: string, - assetType: AssetTypes, + newAsset: DBAssetType, txn: HoarderDBTransaction, ) { - if (newAssetId) { - if (oldAssetId) { - await txn.delete(assets).where(eq(assets.id, oldAssetId)); - } - await txn.insert(assets).values({ - id: newAssetId, - assetType, - bookmarkId, - userId, - }); + if (oldAssetId) { + await txn.delete(assets).where(eq(assets.id, oldAssetId)); } + await txn.insert(assets).values(newAsset); } diff --git a/packages/db/drizzle/0030_blue_synch.sql b/packages/db/drizzle/0030_blue_synch.sql new file mode 100644 index 00000000..9b1bac33 --- /dev/null +++ b/packages/db/drizzle/0030_blue_synch.sql @@ -0,0 +1,12 @@ +ALTER TABLE `assets` ADD `size` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `assets` ADD `contentType` text;--> statement-breakpoint +ALTER TABLE `assets` ADD `fileName` text;--> statement-breakpoint +INSERT INTO `assets` (`id`, `assetType`, `bookmarkId`, `userId`, `fileName`) + SELECT + `bookmarkAssets`.`assetId`, + 'bookmarkAsset', + `bookmarkAssets`.`id`, + (SELECT `bookmarks`.`userId` FROM `bookmarks` WHERE `bookmarks`.`id` = `bookmarkAssets`.`id`), + `bookmarkAssets`.`fileName` + FROM `bookmarkAssets`; + diff --git a/packages/db/drizzle/meta/0030_snapshot.json b/packages/db/drizzle/meta/0030_snapshot.json new file mode 100644 index 00000000..5286196b --- /dev/null +++ b/packages/db/drizzle/meta/0030_snapshot.json @@ -0,0 +1,1222 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3188359f-c324-4fca-be40-4903de779a2d", + "prevId": "e64dea2c-6fd3-4d93-8258-b1f6babe4d07", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4b29c07f..0a357e2d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1728214930701, "tag": "0029_short_gunslinger", "breakpoints": true + }, + { + "idx": 30, + "version": "6", + "when": 1728220453621, + "tag": "0030_blue_synch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index c11f3b92..6098feb1 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -164,6 +164,8 @@ export const enum AssetTypes { LINK_BANNER_IMAGE = "linkBannerImage", LINK_SCREENSHOT = "linkScreenshot", LINK_FULL_PAGE_ARCHIVE = "linkFullPageArchive", + BOOKMARK_ASSET = "bookmarkAsset", + UNKNOWN = "unknown", } export const assets = sqliteTable( @@ -176,11 +178,16 @@ export const assets = sqliteTable( AssetTypes.LINK_BANNER_IMAGE, AssetTypes.LINK_SCREENSHOT, AssetTypes.LINK_FULL_PAGE_ARCHIVE, + AssetTypes.BOOKMARK_ASSET, + AssetTypes.UNKNOWN, ], }).notNull(), - bookmarkId: text("bookmarkId") - .notNull() - .references(() => bookmarks.id, { onDelete: "cascade" }), + size: integer("size").notNull().default(0), + contentType: text("contentType"), + fileName: text("fileName"), + bookmarkId: text("bookmarkId").references(() => bookmarks.id, { + onDelete: "cascade", + }), userId: text("userId") .notNull() .references(() => users.id, { onDelete: "cascade" }), @@ -302,7 +309,6 @@ export const bookmarksInLists = sqliteTable( }), ); - export const customPrompts = sqliteTable( "customPrompts", { @@ -312,7 +318,9 @@ export const customPrompts = sqliteTable( .$defaultFn(() => createId()), text: text("text").notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull(), - appliesTo: text("attachedBy", { enum: ["all", "text", "images"] }).notNull(), + appliesTo: text("attachedBy", { + enum: ["all", "text", "images"], + }).notNull(), createdAt: createdAtField(), userId: text("userId") .notNull() diff --git a/packages/shared/assetdb.ts b/packages/shared/assetdb.ts index dd464139..4edfa1ec 100644 --- a/packages/shared/assetdb.ts +++ b/packages/shared/assetdb.ts @@ -120,6 +120,18 @@ export async function readAsset({ return { asset, metadata }; } +export async function getAssetSize({ + userId, + assetId, +}: { + userId: string; + assetId: string; +}) { + const assetDir = getAssetDir(userId, assetId); + const stat = await fs.promises.stat(path.join(assetDir, "asset.bin")); + return stat.size; +} + export async function deleteAsset({ userId, assetId, diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index 86bbbc1a..f4b4fd4a 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -15,6 +15,8 @@ export const zAssetTypesSchema = z.enum([ "screenshot", "bannerImage", "fullPageArchive", + "bookmarkAsset", + "unknown", ]); export type ZAssetType = z.infer; 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,