From 99a05e3337b3cf9c5c9ced3ae7e18f25796b918a Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 12 Nov 2025 19:50:12 +0000 Subject: [PATCH] feat: add attachments router and repo funcs --- packages/api/src/root.ts | 2 + packages/api/src/routers/attachment.ts | 254 ++++++++++++++++++ .../db/src/repository/cardAttachment.repo.ts | 99 +++++++ 3 files changed, 355 insertions(+) create mode 100644 packages/api/src/routers/attachment.ts create mode 100644 packages/db/src/repository/cardAttachment.repo.ts diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index f106930b..2e0a1016 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,3 +1,4 @@ +import { attachmentRouter } from "./routers/attachment"; import { boardRouter } from "./routers/board"; import { cardRouter } from "./routers/card"; import { checklistRouter } from "./routers/checklist"; @@ -12,6 +13,7 @@ import { workspaceRouter } from "./routers/workspace"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ + attachment: attachmentRouter, board: boardRouter, card: cardRouter, checklist: checklistRouter, diff --git a/packages/api/src/routers/attachment.ts b/packages/api/src/routers/attachment.ts new file mode 100644 index 00000000..9fe27d23 --- /dev/null +++ b/packages/api/src/routers/attachment.ts @@ -0,0 +1,254 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import * as cardRepo from "@kan/db/repository/card.repo"; +import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; +import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { generateUID } from "@kan/shared/utils"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { assertUserInWorkspace } from "../utils/auth"; +import { generateDownloadUrl, generateUploadUrl } from "../utils/s3"; + +export const attachmentRouter = createTRPCRouter({ + generateUploadUrl: protectedProcedure + .meta({ + openapi: { + summary: "Generate presigned URL for attachment upload", + method: "POST", + path: "/cards/{cardPublicId}/attachments/upload-url", + description: + "Generates a presigned URL for uploading an attachment to S3", + tags: ["Attachments"], + protect: true, + }, + }) + .input( + z.object({ + cardPublicId: z.string().min(12), + filename: z.string().min(1).max(255), + contentType: z.string(), + size: z + .number() + .positive() + .max(50 * 1024 * 1024), // 50MB max + }), + ) + .output(z.object({ url: z.string(), key: z.string() })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + ctx.db, + input.cardPublicId, + ); + + if (!card) + throw new TRPCError({ + message: `Card with public ID ${input.cardPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + + // Get workspace publicId + const workspace = await workspaceRepo.getById(ctx.db, card.workspaceId); + if (!workspace) + throw new TRPCError({ + message: `Workspace not found`, + code: "NOT_FOUND", + }); + + const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; + if (!bucket) + throw new TRPCError({ + message: `Attachments bucket not configured`, + code: "INTERNAL_SERVER_ERROR", + }); + + // Sanitize filename + const sanitizedFilename = input.filename + .replace(/[^a-zA-Z0-9._-]/g, "_") + .substring(0, 200); + + // Generate S3 key: {workspacePublicId}/{cardPublicId}/{generateUID()}-{sanitizedFilename} + const s3Key = `${workspace.publicId}/${input.cardPublicId}/${generateUID()}-${sanitizedFilename}`; + + const url = await generateUploadUrl( + bucket, + s3Key, + input.contentType, + 3600, // 1 hour + ); + + return { url, key: s3Key }; + }), + confirm: protectedProcedure + .meta({ + openapi: { + summary: "Confirm attachment upload and save to database", + method: "POST", + path: "/cards/{cardPublicId}/attachments/confirm", + description: + "Confirms an attachment upload and saves the record to the database", + tags: ["Attachments"], + protect: true, + }, + }) + .input( + z.object({ + cardPublicId: z.string().min(12), + s3Key: z.string(), + filename: z.string(), + originalFilename: z.string(), + contentType: z.string(), + size: z.number().positive(), + }), + ) + .output(z.custom>>()) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + ctx.db, + input.cardPublicId, + ); + + if (!card) + throw new TRPCError({ + message: `Card with public ID ${input.cardPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + + const attachment = await cardAttachmentRepo.create(ctx.db, { + cardId: card.id, + filename: input.filename, + originalFilename: input.originalFilename, + contentType: input.contentType, + size: input.size, + s3Key: input.s3Key, + createdBy: userId, + }); + + await cardActivityRepo.create(ctx.db, { + type: "card.updated.attachment.added", + cardId: card.id, + createdBy: userId, + }); + + return attachment; + }), + getUrl: protectedProcedure + .meta({ + openapi: { + summary: "Get presigned URL for attachment download", + method: "GET", + path: "/attachments/{attachmentPublicId}/url", + description: "Generates a presigned URL for downloading an attachment", + tags: ["Attachments"], + protect: true, + }, + }) + .input(z.object({ attachmentPublicId: z.string().min(12) })) + .output(z.object({ url: z.string(), filename: z.string() })) + .query(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const attachment = await cardAttachmentRepo.getByPublicId( + ctx.db, + input.attachmentPublicId, + ); + + if (!attachment || attachment.deletedAt) + throw new TRPCError({ + message: `Attachment with public ID ${input.attachmentPublicId} not found`, + code: "NOT_FOUND", + }); + + const workspaceId = attachment.card.list.board.workspaceId; + + await assertUserInWorkspace(ctx.db, userId, workspaceId); + + const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; + if (!bucket) + throw new TRPCError({ + message: `Attachments bucket not configured`, + code: "INTERNAL_SERVER_ERROR", + }); + + const url = await generateDownloadUrl(bucket, attachment.s3Key, 3600); + + return { url, filename: attachment.originalFilename }; + }), + delete: protectedProcedure + .meta({ + openapi: { + summary: "Delete an attachment", + method: "DELETE", + path: "/attachments/{attachmentPublicId}", + description: "Soft deletes an attachment", + tags: ["Attachments"], + protect: true, + }, + }) + .input(z.object({ attachmentPublicId: z.string().min(12) })) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const attachment = await cardAttachmentRepo.getByPublicId( + ctx.db, + input.attachmentPublicId, + ); + + if (!attachment || attachment.deletedAt) + throw new TRPCError({ + message: `Attachment with public ID ${input.attachmentPublicId} not found`, + code: "NOT_FOUND", + }); + + const workspaceId = attachment.card.list.board.workspaceId; + + await assertUserInWorkspace(ctx.db, userId, workspaceId); + + await cardAttachmentRepo.softDelete(ctx.db, { + attachmentId: attachment.id, + deletedAt: new Date(), + }); + + await cardActivityRepo.create(ctx.db, { + type: "card.updated.attachment.removed", + cardId: attachment.cardId, + createdBy: userId, + }); + + return { success: true }; + }), +}); diff --git a/packages/db/src/repository/cardAttachment.repo.ts b/packages/db/src/repository/cardAttachment.repo.ts new file mode 100644 index 00000000..00f6767d --- /dev/null +++ b/packages/db/src/repository/cardAttachment.repo.ts @@ -0,0 +1,99 @@ +import { and, eq, isNull } from "drizzle-orm"; + +import type { dbClient } from "@kan/db/client"; +import { cardAttachments } from "@kan/db/schema"; +import { generateUID } from "@kan/shared/utils"; + +export const create = async ( + db: dbClient, + attachmentInput: { + cardId: number; + filename: string; + originalFilename: string; + contentType: string; + size: number; + s3Key: string; + createdBy: string; + }, +) => { + const [result] = await db + .insert(cardAttachments) + .values({ + publicId: generateUID(), + cardId: attachmentInput.cardId, + filename: attachmentInput.filename, + originalFilename: attachmentInput.originalFilename, + contentType: attachmentInput.contentType, + size: attachmentInput.size, + s3Key: attachmentInput.s3Key, + createdBy: attachmentInput.createdBy, + }) + .returning({ + id: cardAttachments.id, + publicId: cardAttachments.publicId, + filename: cardAttachments.filename, + originalFilename: cardAttachments.originalFilename, + contentType: cardAttachments.contentType, + size: cardAttachments.size, + s3Key: cardAttachments.s3Key, + createdBy: cardAttachments.createdBy, + createdAt: cardAttachments.createdAt, + }); + + return result; +}; + +export const getByPublicId = (db: dbClient, publicId: string) => { + return db.query.cardAttachments.findFirst({ + where: eq(cardAttachments.publicId, publicId), + with: { + card: { + columns: { + id: true, + publicId: true, + }, + with: { + list: { + columns: { + id: true, + }, + with: { + board: { + columns: { + id: true, + workspaceId: true, + }, + }, + }, + }, + }, + }, + }, + }); +}; + +export const getAllByCardId = (db: dbClient, cardId: number) => { + return db.query.cardAttachments.findMany({ + where: and( + eq(cardAttachments.cardId, cardId), + isNull(cardAttachments.deletedAt), + ), + orderBy: (attachments, { desc }) => [desc(attachments.createdAt)], + }); +}; + +export const softDelete = async ( + db: dbClient, + args: { + attachmentId: number; + deletedAt: Date; + }, +) => { + const [result] = await db + .update(cardAttachments) + .set({ deletedAt: args.deletedAt }) + .where(eq(cardAttachments.id, args.attachmentId)) + .returning({ id: cardAttachments.id }); + + return result; +};