feat: add attachments router and repo funcs
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { attachmentRouter } from "./routers/attachment";
|
||||||
import { boardRouter } from "./routers/board";
|
import { boardRouter } from "./routers/board";
|
||||||
import { cardRouter } from "./routers/card";
|
import { cardRouter } from "./routers/card";
|
||||||
import { checklistRouter } from "./routers/checklist";
|
import { checklistRouter } from "./routers/checklist";
|
||||||
@@ -12,6 +13,7 @@ import { workspaceRouter } from "./routers/workspace";
|
|||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
|
attachment: attachmentRouter,
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
card: cardRouter,
|
card: cardRouter,
|
||||||
checklist: checklistRouter,
|
checklist: checklistRouter,
|
||||||
|
|||||||
254
packages/api/src/routers/attachment.ts
Normal file
254
packages/api/src/routers/attachment.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
99
packages/db/src/repository/cardAttachment.repo.ts
Normal file
99
packages/db/src/repository/cardAttachment.repo.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user