Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): add vercel blob service #316

Merged
merged 1 commit into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "BlobObject" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"uploadedByUserId" TEXT,
"pathname" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"contentDisposition" TEXT,
"url" TEXT NOT NULL,
"downloadUrl" TEXT NOT NULL,
"transactionId" TEXT,

CONSTRAINT "BlobObject_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "BlobObject" ADD CONSTRAINT "BlobObject_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "BlobObject" ADD CONSTRAINT "BlobObject_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
Warnings:

- Made the column `contentDisposition` on table `BlobObject` required. This step will fail if there are existing NULL values in that column.

*/
-- AlterTable
ALTER TABLE "BlobObject" ALTER COLUMN "contentType" DROP NOT NULL,
ALTER COLUMN "contentDisposition" SET NOT NULL;
20 changes: 20 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ model User {
createdBudgetUserInvitations BudgetUserInvitation[]
createdFromInvitation BudgetUserInvitationResponse?
categories Category[]
uploadedBlobObjects BlobObject[]
}

model UserWalletAccount {
Expand Down Expand Up @@ -167,6 +168,7 @@ model Transaction {
walletAccount UserWalletAccount @relation(fields: [walletAccountId], references: [id], onDelete: Cascade)
createdByUserId String
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
blobAttachments BlobObject[]
}

model Category {
Expand Down Expand Up @@ -215,3 +217,21 @@ model CurrencyExchangeRate {

@@unique([fromCurrency, toCurrency, date])
}

model BlobObject {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

uploadedByUserId String?
uploadedByUser User? @relation(fields: [uploadedByUserId], references: [id], onDelete: Cascade)

pathname String
contentType String?
contentDisposition String
url String
downloadUrl String

transactionId String?
transaction Transaction? @relation(fields: [transactionId], references: [id], onDelete: SetNull)
}
131 changes: 131 additions & 0 deletions apps/api/v1/services/blob.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { User } from '@prisma/client'
import {
type PutBlobResult,
type PutCommandOptions,
del,
put,
} from '@vercel/blob'
import { getLogger } from '../../lib/log'
import prisma from '../../lib/prisma'

const BASE_UPLOAD_PATH = process.env.VERCEL_ENV || 'local'

type FindBlobObjectCondition =
| { id: string }
| { pathname: string }
| { url: string }

export async function putBlobObject(args: {
id?: string
pathname: string
file: File
blobOptions?: PutCommandOptions
uploadedUser?: User | null
transactionId?: string | null
}) {
const {
id,
pathname,
file,
blobOptions = { access: 'public' },
uploadedUser,
transactionId,
} = args
const logger = getLogger(`blob.service:${putBlobObject.name}`)
const blobPathname = `${BASE_UPLOAD_PATH}/${pathname}`

logger.debug('Uploading blob %o', { ...args, blobPathname })

let blob: PutBlobResult

try {
blob = await put(blobPathname, file, blobOptions)
} catch (error) {
logger.error('Error uploading blob %o', error)
throw error
}

logger.info('Uploaded blob %o', blob)
logger.debug('Saving blob metadata...')

// Save blob metadata to database
const blobObject = await prisma.blobObject.create({
data: {
id,
pathname: blob.pathname,
contentType: blob.contentType,
contentDisposition: blob.contentDisposition,
downloadUrl: blob.downloadUrl,
url: blob.url,
uploadedByUser: uploadedUser
? { connect: { id: uploadedUser.id } }
: undefined,
transaction: transactionId
? { connect: { id: transactionId } }
: undefined,
},
})

logger.info('Saved blob metadata %o', blobObject)

return blobObject
}

export async function getBlobObject(condition: FindBlobObjectCondition) {
const logger = getLogger(`blob.service:${getBlobObject.name}`)
logger.debug('Getting blob %o', condition)

const blobObject = await prisma.blobObject.findFirst({
where: condition,
})

logger.debug('Got blob %o', blobObject)

return blobObject
}

export async function deleteBlobObject(condition: FindBlobObjectCondition) {
const logger = getLogger(`blob.service:${deleteBlobObject.name}`)
logger.debug('Deleting blob %o', condition)

const blobObject = await getBlobObject(condition)

if (!blobObject) {
logger.warn('Blob not found in DB. Condition: %o', condition)
} else {
logger.debug('Deleting blob from DB %o', blobObject)

await prisma.blobObject.delete({
where: { id: blobObject.id },
})

logger.info('Deleted blob from DB %o', blobObject)
}

const blobUrl =
blobObject?.url || ('url' in condition && condition.url) || null

if (!blobUrl) {
logger.warn('Blob URL not found. Abort deleting.')
return false
}

// Delete blob on Vercel Blob
try {
logger.debug('Deleting blob on Vercel with URL: %s', blobUrl)

await del(blobUrl)

logger.info('Deleted blob on Vercel with URL: %s', blobUrl)
} catch (error) {
logger.error(
'Error deleting blob on Vercel with URL %s. Error: %o',
blobUrl,
error,
)

return false
}

return true
}
Loading