feat: add attachments router and repo funcs

This commit is contained in:
Henry
2025-11-12 19:50:12 +00:00
parent 4294ee3811
commit 99a05e3337
3 changed files with 355 additions and 0 deletions

View File

@@ -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,

View File

@@ -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<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
.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 };
}),
});

View File

@@ -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;
};