diff --git a/apps/web/src/providers/workspace.tsx b/apps/web/src/providers/workspace.tsx
index 58a5b46a..87fdd24b 100644
--- a/apps/web/src/providers/workspace.tsx
+++ b/apps/web/src/providers/workspace.tsx
@@ -17,6 +17,7 @@ interface Workspace {
publicId: string;
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
+ role: "admin" | "member" | "guest";
}
const initialWorkspace: Workspace = {
@@ -25,6 +26,7 @@ const initialWorkspace: Workspace = {
publicId: "",
slug: "",
plan: "free",
+ role: "member",
};
const initialAvailableWorkspaces: Workspace[] = [];
@@ -60,10 +62,11 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
if (data.length) {
const workspaces = data
- .map(({ workspace }) => {
+ .map(({ workspace, role }) => {
if (!workspace) return;
return {
+ role,
publicId: workspace.publicId,
name: workspace.name,
slug: workspace.slug,
@@ -90,10 +93,13 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description,
+ role: selectedWorkspace.role,
});
} else {
const primaryWorkspace = data[0]?.workspace;
- if (!primaryWorkspace) return;
+ const primaryWorkspaceRole = data[0]?.role;
+
+ if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({
publicId: primaryWorkspace.publicId,
@@ -101,6 +107,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan,
description: primaryWorkspace.description,
+ role: primaryWorkspaceRole,
});
}
}, [data]);
diff --git a/apps/web/src/views/card/components/ActivityList.tsx b/apps/web/src/views/card/components/ActivityList.tsx
index 407fd46a..0c84755e 100644
--- a/apps/web/src/views/card/components/ActivityList.tsx
+++ b/apps/web/src/views/card/components/ActivityList.tsx
@@ -12,6 +12,7 @@ import {
import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar";
+import { useWorkspace } from "~/providers/workspace";
import Comment from "./Comment";
type ActivityType =
@@ -145,6 +146,8 @@ const ActivityList = ({
cardPublicId: string;
isLoading: boolean;
}) => {
+ const { workspace } = useWorkspace();
+
return (
{activities.map((activity, index) => {
@@ -170,6 +173,8 @@ const ActivityList = ({
createdAt={activity.createdAt}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
+ isAuthor={activity.comment?.createdBy === activity.user?.id}
+ isAdmin={workspace.role === "admin"}
/>
);
diff --git a/apps/web/src/views/card/components/Comment.tsx b/apps/web/src/views/card/components/Comment.tsx
index 64dc61e1..707df1c0 100644
--- a/apps/web/src/views/card/components/Comment.tsx
+++ b/apps/web/src/views/card/components/Comment.tsx
@@ -2,11 +2,12 @@ import { formatDistanceToNow } from "date-fns";
import { useState } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form";
-import { HiEllipsisHorizontal, HiPencil } from "react-icons/hi2";
+import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown";
+import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -22,6 +23,8 @@ const Comment = ({
isLoading,
createdAt,
comment,
+ isAuthor,
+ isAdmin,
isEdited = false,
}: {
publicId: string | undefined;
@@ -31,11 +34,14 @@ const Comment = ({
isLoading: boolean;
createdAt: string;
comment: string | undefined;
+ isAuthor: boolean;
+ isAdmin: boolean;
isEdited: boolean;
}) => {
const [isEditing, setIsEditing] = useState(false);
const utils = api.useUtils();
const { showPopup } = usePopup();
+ const { openModal } = useModal();
const { handleSubmit, setValue, watch } = useForm
({
defaultValues: {
comment,
@@ -66,6 +72,27 @@ const Comment = ({
});
};
+ const dropdownItems = [
+ ...(isAuthor
+ ? [
+ {
+ label: "Edit comment",
+ action: () => setIsEditing(true),
+ icon: ,
+ },
+ ]
+ : []),
+ ...(isAuthor || isAdmin
+ ? [
+ {
+ label: "Delete comment",
+ action: () => openModal("DELETE_COMMENT", publicId),
+ icon: ,
+ },
+ ]
+ : []),
+ ];
+
return (
-
- setIsEditing(true),
- icon: ,
- },
- ]}
- >
-
-
-
+ {dropdownItems.length > 0 && (
+
+
+
+
+
+ )}
{!isEditing ? (
{comment}
diff --git a/apps/web/src/views/card/components/DeleteCommentConfirmation.tsx b/apps/web/src/views/card/components/DeleteCommentConfirmation.tsx
new file mode 100644
index 00000000..ee34fe28
--- /dev/null
+++ b/apps/web/src/views/card/components/DeleteCommentConfirmation.tsx
@@ -0,0 +1,85 @@
+import Button from "~/components/Button";
+import { useModal } from "~/providers/modal";
+import { usePopup } from "~/providers/popup";
+import { api } from "~/utils/api";
+
+interface DeleteCommentConfirmationProps {
+ cardPublicId: string;
+ commentPublicId: string;
+}
+
+export function DeleteCommentConfirmation({
+ cardPublicId,
+ commentPublicId,
+}: DeleteCommentConfirmationProps) {
+ const { closeModal } = useModal();
+ const utils = api.useUtils();
+ const { showPopup } = usePopup();
+
+ const queryParams = {
+ cardPublicId,
+ };
+
+ const deleteCommentMutation = api.card.deleteComment.useMutation({
+ onMutate: async (args) => {
+ closeModal();
+ await utils.card.byId.cancel();
+ const currentState = utils.card.byId.getData(queryParams);
+
+ utils.card.byId.setData(queryParams, (oldCard) => {
+ if (!oldCard) return oldCard;
+ const updatedActivities = oldCard.activities.filter(
+ (activity) => activity.comment?.publicId !== args.commentPublicId,
+ );
+ return { ...oldCard, activities: updatedActivities };
+ });
+
+ return { previousState: currentState };
+ },
+ onError: (_error, _newList, context) => {
+ utils.card.byId.setData(queryParams, context?.previousState);
+ showPopup({
+ header: "Unable to delete comment",
+ message: "Please try again later, or contact customer support.",
+ icon: "error",
+ });
+ },
+ onSettled: async () => {
+ await utils.card.byId.invalidate(queryParams);
+ },
+ });
+
+ const handleDeleteComment = () => {
+ deleteCommentMutation.mutate({
+ cardPublicId,
+ commentPublicId,
+ });
+ };
+
+ return (
+
+
+
+ Are you sure you want to delete this comment?
+
+
+ {"This action can't be undone."}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx
index 6dcd77a2..b90894c7 100644
--- a/apps/web/src/views/card/components/NewCommentForm.tsx
+++ b/apps/web/src/views/card/components/NewCommentForm.tsx
@@ -19,18 +19,25 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
},
});
+ const queryParams = {
+ cardPublicId,
+ };
+
const addCommentMutation = api.card.addComment.useMutation({
- onSuccess: async () => {
- await utils.card.byId.refetch();
- reset();
- },
- onError: () => {
+ onError: (_error, _newList) => {
showPopup({
header: "Unable to add comment",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
+ onSettled: async () => {
+ reset();
+ await utils.card.byId.invalidate(queryParams);
+ },
+ onSuccess: async () => {
+ await utils.card.byId.refetch();
+ },
});
const onSubmit = (data: FormValues) => {
diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx
index f30e3ba2..36505705 100644
--- a/apps/web/src/views/card/index.tsx
+++ b/apps/web/src/views/card/index.tsx
@@ -16,6 +16,7 @@ import { formatMemberDisplayName } from "~/utils/helpers";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
import ActivityList from "./components/ActivityList";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
+import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation";
import Dropdown from "./components/Dropdown";
import { LabelForm } from "./components/LabelForm";
@@ -259,6 +260,12 @@ export default function CardPage() {
cardPublicId={cardId}
/>
)}
+ {modalContentType === "DELETE_COMMENT" && (
+
+ )}
{modalContentType === "NEW_WORKSPACE" && }
diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts
index f76a09c3..d24b2c4a 100644
--- a/packages/api/src/routers/card.ts
+++ b/packages/api/src/routers/card.ts
@@ -300,6 +300,71 @@ export const cardRouter = createTRPCRouter({
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>>())
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.user?.id;
+
+ if (!userId)
+ throw new TRPCError({
+ message: `User not authenticated`,
+ code: "UNAUTHORIZED",
+ });
+
+ const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
+ const existingComment = await cardCommentRepo.getByPublicId(
+ ctx.db,
+ input.commentPublicId,
+ );
+
+ if (!card)
+ throw new TRPCError({
+ message: `Card with public ID ${input.cardPublicId} not found`,
+ code: "NOT_FOUND",
+ });
+
+ 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().toISOString(),
+ 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: {
diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts
index ef3ccece..3413713f 100644
--- a/packages/db/src/repository/card.repo.ts
+++ b/packages/db/src/repository/card.repo.ts
@@ -302,6 +302,7 @@ export const getWithListAndMembersByPublicId = async (
.is("deletedAt", null)
.is("list.board.lists.deletedAt", null)
.is("list.board.workspace.members.deletedAt", null)
+ .is("activities.comment.deletedAt", null)
.order("index", { referencedTable: "list.board.lists", ascending: true })
.is("members.deletedAt", null)
.limit(1)
diff --git a/packages/db/src/repository/cardComment.repo.ts b/packages/db/src/repository/cardComment.repo.ts
index 8df47a2b..e96ddc13 100644
--- a/packages/db/src/repository/cardComment.repo.ts
+++ b/packages/db/src/repository/cardComment.repo.ts
@@ -61,3 +61,23 @@ export const update = async (
return data;
};
+
+export const softDelete = async (
+ db: SupabaseClient,
+ args: {
+ commentId: number;
+ deletedAt: string;
+ deletedBy: string;
+ },
+) => {
+ const { data } = await db
+ .from("card_comments")
+ .update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
+ .eq("id", args.commentId)
+ .select(`id`)
+ .order("id", { ascending: true })
+ .limit(1)
+ .single();
+
+ return data;
+};