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