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:
Henry
2025-11-19 21:34:43 +00:00
committed by GitHub
parent af6490896a
commit b472c5ce93
45 changed files with 5120 additions and 581 deletions

View File

@@ -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:^",

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,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 };
}),
});

View File

@@ -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({

View 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 },
);
}

View File

@@ -0,0 +1,29 @@
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.attachment.added' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.attachment.removed' BEFORE 'card.archived';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "card_attachment" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"cardId" bigint NOT NULL,
"filename" varchar(255) NOT NULL,
"originalFilename" varchar(255) NOT NULL,
"contentType" varchar(100) NOT NULL,
"size" bigint NOT NULL,
"s3Key" varchar(500) NOT NULL,
"createdBy" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"deletedAt" timestamp,
CONSTRAINT "card_attachment_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "card_attachment" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_attachment" ADD CONSTRAINT "card_attachment_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_attachment" ADD CONSTRAINT "card_attachment_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@
"when": 1760044396109,
"tag": "20251009211316_AddBoardSourceIdToCardActivity",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1762807463569,
"tag": "20251110204423_AddCardAttachments",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,7 @@ import type { BoardVisibilityStatus } from "@kan/db/schema";
import {
boards,
cardActivities,
cardAttachments,
cards,
cardsToLabels,
cardToWorkspaceMembers,
@@ -196,6 +197,13 @@ export const getByPublicId = async (
},
},
},
attachments: {
columns: {
publicId: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
checklists: {
columns: {
publicId: true,
@@ -358,6 +366,13 @@ export const getBySlug = async (
},
},
},
attachments: {
columns: {
publicId: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
comments: {
columns: {
publicId: true,

View File

@@ -3,6 +3,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import {
cardActivities,
cardAttachments,
cards,
cardsToLabels,
cardToWorkspaceMembers,
@@ -409,6 +410,17 @@ export const getWithListAndMembersByPublicId = async (
},
},
},
attachments: {
columns: {
publicId: true,
contentType: true,
s3Key: true,
originalFilename: true,
size: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
checklists: {
columns: {
publicId: 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;
};

View File

@@ -42,6 +42,8 @@ export const activityTypes = [
"card.updated.checklist.item.completed",
"card.updated.checklist.item.uncompleted",
"card.updated.checklist.item.deleted",
"card.updated.attachment.added",
"card.updated.attachment.removed",
"card.archived",
] as const;
@@ -96,6 +98,7 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
comments: many(comments),
activities: many(cardActivities),
checklists: many(checklists),
attachments: many(cardAttachments),
}));
export const cardActivities = pgTable("card_activity", {
@@ -273,3 +276,37 @@ export const commentsRelations = relations(comments, ({ one }) => ({
relationName: "commentsDeletedByUser",
}),
}));
export const cardAttachments = pgTable("card_attachment", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
filename: varchar("filename", { length: 255 }).notNull(),
originalFilename: varchar("originalFilename", { length: 255 }).notNull(),
contentType: varchar("contentType", { length: 100 }).notNull(),
size: bigint("size", { mode: "number" }).notNull(),
s3Key: varchar("s3Key", { length: 500 }).notNull(),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
deletedAt: timestamp("deletedAt"),
}).enableRLS();
export const cardAttachmentsRelations = relations(
cardAttachments,
({ one }) => ({
card: one(cards, {
fields: [cardAttachments.cardId],
references: [cards.id],
relationName: "cardAttachmentsCard",
}),
createdBy: one(users, {
fields: [cardAttachments.createdBy],
references: [users.id],
relationName: "cardAttachmentsCreatedByUser",
}),
}),
);