feat: card attachments (#247)
* feat: add card attachment schema * feat: setup s3 util funcs * feat: add attachments router and repo funcs * feat: add upload button * feat: add thumbnails and attachment viewer * feat: add download and delete button * feat: add file viewer and downloads * feat: update compose and readme * chore: build lang * feat: display attachments on public boards * chore: update compiled translations
This commit is contained in:
@@ -35,6 +35,8 @@
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:*",
|
||||
"@kan/email": "workspace:^",
|
||||
|
||||
@@ -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,
|
||||
|
||||
205
packages/api/src/routers/attachment.ts
Normal file
205
packages/api/src/routers/attachment.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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 { 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);
|
||||
|
||||
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;
|
||||
}),
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { generateDownloadUrl } from "../utils/s3";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -574,7 +575,21 @@ export const cardRouter = createTRPCRouter({
|
||||
.input(z.object({ cardPublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
Omit<
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
>,
|
||||
"attachments"
|
||||
> & {
|
||||
attachments: {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
url: string | null;
|
||||
}[];
|
||||
}
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -612,7 +627,46 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
return result;
|
||||
// Generate URLs for all attachments
|
||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (result.attachments && Array.isArray(result.attachments)) {
|
||||
const attachments = result.attachments as {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
}[];
|
||||
|
||||
const attachmentsWithUrls = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const base = {
|
||||
publicId: attachment.publicId,
|
||||
contentType: attachment.contentType,
|
||||
s3Key: attachment.s3Key,
|
||||
originalFilename: attachment.originalFilename,
|
||||
size: attachment.size,
|
||||
};
|
||||
if (!bucket || !attachment.s3Key) {
|
||||
return { ...base, url: null };
|
||||
}
|
||||
try {
|
||||
const url = await generateDownloadUrl(
|
||||
bucket,
|
||||
attachment.s3Key,
|
||||
86400, // 24 hours expiration
|
||||
);
|
||||
return { ...base, url };
|
||||
} catch {
|
||||
// If URL generation fails, return attachment with url: null
|
||||
return { ...base, url: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...result, attachments: attachmentsWithUrls };
|
||||
}
|
||||
|
||||
return { ...result, attachments: [] };
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
|
||||
53
packages/api/src/utils/s3.ts
Normal file
53
packages/api/src/utils/s3.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export function createS3Client() {
|
||||
return new S3Client({
|
||||
region: process.env.S3_REGION ?? "",
|
||||
endpoint: process.env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateUploadUrl(
|
||||
bucket: string,
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresIn = 3600,
|
||||
) {
|
||||
const client = createS3Client();
|
||||
return getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
// Don't set ACL for private files
|
||||
}),
|
||||
{ expiresIn },
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateDownloadUrl(
|
||||
bucket: string,
|
||||
key: string,
|
||||
expiresIn = 3600,
|
||||
) {
|
||||
const client = createS3Client();
|
||||
return getSignedUrl(
|
||||
client,
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
{ expiresIn },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user