Files
kan/packages/api/src/routers/card.ts
Ridham Khandar 57186b6b4d feat: paginate and merge frequent activities (#274)
* feat: paginate and merge frequent activities

* feat: due date activities

* fix: requested changes fixes

* refactor: slight reshuffle and improve types

* chore: translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-12-16 09:07:08 +00:00

980 lines
27 KiB
TypeScript

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 cardCommentRepo from "@kan/db/repository/cardComment.repo";
import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { assertUserInWorkspace } from "../utils/auth";
import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Create a card",
method: "POST",
path: "/cards",
description: "Creates a new card for a given list",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
title: z.string().min(1),
description: z.string().max(10000),
listPublicId: z.string().min(12),
labelPublicIds: z.array(z.string().min(12)),
memberPublicIds: z.array(z.string().min(12)),
position: z.enum(["start", "end"]),
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const list = await listRepo.getWorkspaceAndListIdByListPublicId(
ctx.db,
input.listPublicId,
);
if (!list)
throw new TRPCError({
message: `List with public ID ${input.listPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, list.workspaceId);
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const newCard = await cardRepo.create(ctx.db, {
title: input.title,
description: input.description,
createdBy: userId,
listId: list.id,
position: input.position,
dueDate: input.dueDate ?? null,
});
const newCardId = newCard.id;
if (!newCardId)
throw new TRPCError({
message: `Failed to create card`,
code: "INTERNAL_SERVER_ERROR",
});
if (newCardId && input.labelPublicIds.length) {
const labels = await labelRepo.getAllByPublicIds(
ctx.db,
input.labelPublicIds,
);
if (!labels.length)
throw new TRPCError({
message: `Labels with public IDs (${input.labelPublicIds.join(", ")}) not found`,
code: "NOT_FOUND",
});
const labelsInsert = labels.map((label) => ({
cardId: newCardId,
labelId: label.id,
}));
const cardLabels = await cardRepo.bulkCreateCardLabelRelationships(
ctx.db,
labelsInsert,
);
if (!cardLabels.length)
throw new TRPCError({
message: `Failed to create card label relationships`,
code: "INTERNAL_SERVER_ERROR",
});
const cardActivitesInsert = cardLabels.map((cardLabel) => ({
type: "card.updated.label.added" as const,
cardId: cardLabel.cardId,
labelId: cardLabel.labelId,
createdBy: userId,
}));
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
}
if (newCardId && input.memberPublicIds.length) {
const members = await workspaceRepo.getAllMembersByPublicIds(
ctx.db,
input.memberPublicIds,
);
if (!members.length)
throw new TRPCError({
message: `Members with public IDs (${input.memberPublicIds.join(", ")}) not found`,
code: "NOT_FOUND",
});
const membersInsert = members.map((member) => ({
cardId: newCardId,
workspaceMemberId: member.id,
}));
const cardMembers =
await cardRepo.bulkCreateCardWorkspaceMemberRelationships(
ctx.db,
membersInsert,
);
if (!cardMembers.length)
throw new TRPCError({
message: `Failed to create card member relationships`,
code: "INTERNAL_SERVER_ERROR",
});
const cardActivitesInsert = cardMembers.map((cardMember) => ({
type: "card.updated.member.added" as const,
cardId: cardMember.cardId,
workspaceMemberId: cardMember.workspaceMemberId,
createdBy: userId,
}));
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
}
return newCard;
}),
addComment: protectedProcedure
.meta({
openapi: {
summary: "Add a comment to a card",
method: "POST",
path: "/cards/{cardPublicId}/comments",
description: "Adds a comment to a card",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.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 newComment = await cardCommentRepo.create(ctx.db, {
comment: input.comment,
createdBy: userId,
cardId: card.id,
});
if (!newComment?.id)
throw new TRPCError({
message: `Failed to create comment`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.comment.added" as const,
cardId: card.id,
commentId: newComment.id,
toComment: newComment.comment,
createdBy: userId,
});
return newComment;
}),
updateComment: protectedProcedure
.meta({
openapi: {
summary: "Update a comment",
method: "PUT",
path: "/cards/{cardPublicId}/comments/{commentPublicId}",
description: "Updates a comment",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
commentPublicId: z.string().min(12),
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
.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 existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
input.commentPublicId,
);
if (!existingComment)
throw new TRPCError({
message: `Comment with public ID ${input.commentPublicId} not found`,
code: "NOT_FOUND",
});
if (existingComment.createdBy !== userId)
throw new TRPCError({
message: `You do not have permission to update this comment`,
code: "FORBIDDEN",
});
const updatedComment = await cardCommentRepo.update(ctx.db, {
id: existingComment.id,
comment: input.comment,
});
if (!updatedComment?.id)
throw new TRPCError({
message: `Failed to update comment`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.comment.updated" as const,
cardId: card.id,
commentId: updatedComment.id,
fromComment: existingComment.comment,
toComment: updatedComment.comment,
createdBy: userId,
});
return updatedComment;
}),
deleteComment: protectedProcedure
.meta({
openapi: {
summary: "Delete a comment",
method: "DELETE",
path: "/cards/{cardPublicId}/comments/{commentPublicId}",
description: "Deletes a comment",
tags: ["Cards"],
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
commentPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.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 existingComment = await cardCommentRepo.getByPublicId(
ctx.db,
input.commentPublicId,
);
if (!existingComment)
throw new TRPCError({
message: `Comment with public ID ${input.commentPublicId} not found`,
code: "NOT_FOUND",
});
const deletedComment = await cardCommentRepo.softDelete(ctx.db, {
commentId: existingComment.id,
deletedAt: new Date(),
deletedBy: userId,
});
if (!deletedComment)
throw new TRPCError({
message: `Failed to delete comment`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.comment.deleted" as const,
cardId: card.id,
commentId: existingComment.id,
createdBy: userId,
});
return deletedComment;
}),
addOrRemoveLabel: protectedProcedure
.meta({
openapi: {
summary: "Add or remove a label from a card",
method: "PUT",
path: "/cards/{cardPublicId}/labels/{labelPublicId}",
description: "Adds or removes a label from a card",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
labelPublicId: z.string().min(12),
}),
)
.output(z.object({ newLabel: z.boolean() }))
.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 label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
if (!label)
throw new TRPCError({
message: `Label with public ID ${input.labelPublicId} not found`,
code: "NOT_FOUND",
});
const cardLabelIds = { cardId: card.id, labelId: label.id };
const existingLabel = await cardRepo.getCardLabelRelationship(
ctx.db,
cardLabelIds,
);
if (existingLabel) {
const deletedCardLabelRelationship =
await cardRepo.hardDeleteCardLabelRelationship(ctx.db, cardLabelIds);
if (!deletedCardLabelRelationship)
throw new TRPCError({
message: `Failed to remove label from card`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.label.removed" as const,
cardId: card.id,
labelId: label.id,
createdBy: userId,
});
return { newLabel: false };
}
const newCardLabelRelationship =
await cardRepo.createCardLabelRelationship(ctx.db, cardLabelIds);
if (!newCardLabelRelationship)
throw new TRPCError({
message: `Failed to add label to card`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.label.added" as const,
cardId: card.id,
labelId: label.id,
createdBy: userId,
});
return { newLabel: true };
}),
addOrRemoveMember: protectedProcedure
.meta({
openapi: {
summary: "Add or remove a member from a card",
method: "PUT",
path: "/cards/{cardPublicId}/members/{workspaceMemberPublicId}",
description: "Adds or removes a member from a card",
tags: ["Cards"],
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
workspaceMemberPublicId: z.string().min(12),
}),
)
.output(z.object({ newMember: z.boolean() }))
.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 member = await workspaceRepo.getMemberByPublicId(
ctx.db,
input.workspaceMemberPublicId,
);
if (!member)
throw new TRPCError({
message: `Member with public ID ${input.workspaceMemberPublicId} not found`,
code: "NOT_FOUND",
});
const cardMemberIds = { cardId: card.id, memberId: member.id };
const existingMember = await cardRepo.getCardMemberRelationship(
ctx.db,
cardMemberIds,
);
if (existingMember) {
const deletedCardMemberRelationship =
await cardRepo.hardDeleteCardMemberRelationship(
ctx.db,
cardMemberIds,
);
if (!deletedCardMemberRelationship.success)
throw new TRPCError({
message: `Failed to remove member from card`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.member.removed" as const,
cardId: card.id,
workspaceMemberId: member.id,
createdBy: userId,
});
return { newMember: false };
}
const newCardMemberRelationship =
await cardRepo.createCardMemberRelationship(ctx.db, cardMemberIds);
if (!newCardMemberRelationship.success)
throw new TRPCError({
message: `Failed to add member to card`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.member.added" as const,
cardId: card.id,
workspaceMemberId: member.id,
createdBy: userId,
});
return { newMember: true };
}),
byId: publicProcedure
.meta({
openapi: {
summary: "Get a card by public ID",
method: "GET",
path: "/cards/{cardPublicId}",
description: "Retrieves a card by its public ID",
tags: ["Cards"],
},
})
.input(z.object({ cardPublicId: z.string().min(12) }))
.output(
z.custom<
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 }) => {
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",
});
if (card.workspaceVisibility === "private") {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
}
const result = await cardRepo.getWithListAndMembersByPublicId(
ctx.db,
input.cardPublicId,
);
if (!result)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
// 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: [] };
}),
getActivities: publicProcedure
.meta({
openapi: {
summary: "Get paginated card activities",
method: "GET",
path: "/cards/{cardPublicId}/activities",
description:
"Retrieves paginated activities for a card with merged frequent changes",
tags: ["Cards"],
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
limit: z.number().min(1).max(100).optional().default(10),
cursor: z.string().datetime().optional(), // ISO datetime string
}),
)
.output(
z.object({
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
)
.query(async ({ ctx, input }) => {
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",
});
if (card.workspaceVisibility === "private") {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
}
const cursor = input.cursor ? new Date(input.cursor) : undefined;
const result = await cardActivityRepo.getPaginatedActivities(
ctx.db,
card.id,
{
limit: input.limit,
cursor,
},
);
const mergedActivities = mergeActivities(result.activities);
return {
activities: mergedActivities,
hasMore: result.hasMore,
nextCursor: result.nextCursor?.toISOString() ?? null,
};
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a card",
method: "PUT",
path: "/cards/{cardPublicId}",
description: "Updates a card by its public ID",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
title: z.string().min(1).optional(),
description: z.string().optional(),
index: z.number().optional(),
listPublicId: z.string().min(12).optional(),
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
.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 existingCard = await cardRepo.getByPublicId(
ctx.db,
input.cardPublicId,
);
let newListId: number | undefined;
if (input.listPublicId) {
const newList = await listRepo.getByPublicId(
ctx.db,
input.listPublicId,
);
if (!newList)
throw new TRPCError({
message: `List with public ID ${input.listPublicId} not found`,
code: "NOT_FOUND",
});
newListId = newList.id;
}
if (!existingCard) {
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
}
let result:
| {
id: number;
title: string;
description: string | null;
publicId: string;
dueDate: Date | null;
}
| undefined;
const previousDueDate = existingCard.dueDate;
if (input.title || input.description || input.dueDate !== undefined) {
result = await cardRepo.update(
ctx.db,
{
...(input.title && { title: input.title }),
...(input.description && { description: input.description }),
...(input.dueDate !== undefined && { dueDate: input.dueDate }),
},
{ cardPublicId: input.cardPublicId },
);
}
if (input.index !== undefined) {
result = await cardRepo.reorder(ctx.db, {
cardId: existingCard.id,
newIndex: input.index,
newListId: newListId,
});
}
if (!result)
throw new TRPCError({
message: `Failed to update card`,
code: "INTERNAL_SERVER_ERROR",
});
const activities = [];
if (input.title && existingCard.title !== input.title) {
activities.push({
type: "card.updated.title" as const,
cardId: result.id,
createdBy: userId,
fromTitle: existingCard.title,
toTitle: input.title,
});
}
if (input.description && existingCard.description !== input.description) {
activities.push({
type: "card.updated.description" as const,
cardId: result.id,
createdBy: userId,
fromDescription: existingCard.description ?? undefined,
toDescription: input.description,
});
}
if (
input.dueDate !== undefined &&
previousDueDate?.getTime() !== input.dueDate?.getTime()
) {
let activityType:
| "card.updated.dueDate.added"
| "card.updated.dueDate.updated"
| "card.updated.dueDate.removed";
if (!previousDueDate) {
activityType = "card.updated.dueDate.added";
} else if (!input.dueDate) {
activityType = "card.updated.dueDate.removed";
} else {
activityType = "card.updated.dueDate.updated";
}
activities.push({
type: activityType,
cardId: result.id,
createdBy: userId,
fromDueDate: previousDueDate ?? undefined,
toDueDate: input.dueDate ?? undefined,
});
}
if (newListId && existingCard.listId !== newListId) {
activities.push({
type: "card.updated.list" as const,
cardId: result.id,
createdBy: userId,
fromListId: existingCard.listId,
toListId: newListId,
});
}
if (activities.length > 0) {
await cardActivityRepo.bulkCreate(ctx.db, activities);
}
return result;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a card",
method: "DELETE",
path: "/cards/{cardPublicId}",
description: "Deletes a card by its public ID",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: 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 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 deletedAt = new Date();
await cardRepo.softDelete(ctx.db, {
cardId: card.id,
deletedAt,
deletedBy: userId,
});
await cardActivityRepo.create(ctx.db, {
type: "card.archived",
cardId: card.id,
createdBy: userId,
});
return { success: true };
}),
});