feat: create card activity from snapshot creation

This commit is contained in:
Henry
2025-10-10 13:45:01 +01:00
parent 8ba991a0d3
commit a41fe6ea3d
9 changed files with 2947 additions and 4 deletions

View File

@@ -1,3 +1,4 @@
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -10,6 +11,7 @@ import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import Toggle from "~/components/Toggle"; import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import TemplateBoards from "./TemplateBoards"; import TemplateBoards from "./TemplateBoards";
@@ -32,6 +34,8 @@ interface NewBoardInputWithTemplate {
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) { export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
const utils = api.useUtils(); const utils = api.useUtils();
const { closeModal } = useModal(); const { closeModal } = useModal();
const router = useRouter();
const { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const [showTemplates, setShowTemplates] = useState(false); const [showTemplates, setShowTemplates] = useState(false);
const { data: templates } = api.board.all.useQuery( const { data: templates } = api.board.all.useQuery(
@@ -41,6 +45,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
const formattedTemplates = templates?.map((template) => ({ const formattedTemplates = templates?.map((template) => ({
id: template.publicId, id: template.publicId,
sourceBoardPublicId: template.publicId,
name: template.name, name: template.name,
lists: template.lists.map((list) => list.name), lists: template.lists.map((list) => list.name),
labels: template.labels.map((label) => label.name), labels: template.labels.map((label) => label.name),
@@ -66,16 +71,36 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
const refetchBoards = () => utils.board.all.refetch(); const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({ const createBoard = api.board.create.useMutation({
onSuccess: async () => { onSuccess: async (board) => {
if (!board) {
showPopup({
header: t`Error`,
message: t`Failed to create board`,
icon: "error",
});
} else {
router.push(
`${isTemplate ? "/templates" : "/boards"}/${board.publicId}`,
);
}
closeModal(); closeModal();
await refetchBoards(); await refetchBoards();
}, },
onError: () => {
showPopup({
header: t`Error`,
message: t`Failed to create board`,
icon: "error",
});
},
}); });
const onSubmit = (data: NewBoardInputWithTemplate) => { const onSubmit = (data: NewBoardInputWithTemplate) => {
createBoard.mutate({ createBoard.mutate({
name: data.name, name: data.name,
workspacePublicId: data.workspacePublicId, workspacePublicId: data.workspacePublicId,
sourceBoardPublicId: data.template?.sourceBoardPublicId ?? undefined,
lists: data.template?.lists ?? [], lists: data.template?.lists ?? [],
labels: data.template?.labels ?? [], labels: data.template?.labels ?? [],
type: isTemplate ? "template" : "regular", type: isTemplate ? "template" : "regular",

View File

@@ -4,6 +4,7 @@ import { HiCheckCircle } from "react-icons/hi2";
export interface Template { export interface Template {
id: string; id: string;
sourceBoardPublicId?: string;
name: string; name: string;
lists: string[]; lists: string[];
labels: string[]; labels: string[];

View File

@@ -215,13 +215,26 @@ export const boardRouter = createTRPCRouter({
// If sourceBoardPublicId is provided, clone the source board // If sourceBoardPublicId is provided, clone the source board
if (input.sourceBoardPublicId) { if (input.sourceBoardPublicId) {
// First get the source board info (ID and type)
const sourceBoardInfo = await boardRepo.getIdByPublicId(
ctx.db,
input.sourceBoardPublicId,
);
if (!sourceBoardInfo)
throw new TRPCError({
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
code: "NOT_FOUND",
});
// Get the full board data with the correct type
const sourceBoard = await boardRepo.getByPublicId( const sourceBoard = await boardRepo.getByPublicId(
ctx.db, ctx.db,
input.sourceBoardPublicId, input.sourceBoardPublicId,
{ {
members: [], members: [],
labels: [], labels: [],
type: undefined, type: sourceBoardInfo.type,
}, },
); );
@@ -260,6 +273,7 @@ export const boardRouter = createTRPCRouter({
slug, slug,
name: input.name, name: input.name,
type: input.type ?? "regular", type: input.type ?? "regular",
sourceBoardId: sourceBoardInfo.id,
}); });
if (!result) if (!result)

View File

@@ -0,0 +1,6 @@
ALTER TABLE "card_activity" ADD COLUMN "sourceBoardId" bigint;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_sourceBoardId_board_id_fk" FOREIGN KEY ("sourceBoardId") REFERENCES "public"."board"("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

@@ -127,6 +127,13 @@
"when": 1759869689304, "when": 1759869689304,
"tag": "20251007204129_AddBoardTypeAndSourceIdColumns", "tag": "20251007204129_AddBoardTypeAndSourceIdColumns",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1760044396109,
"tag": "20251009211316_AddBoardSourceIdToCardActivity",
"breakpoints": true
} }
] ]
} }

View File

@@ -4,6 +4,7 @@ import type { dbClient } from "@kan/db/client";
import type { BoardVisibilityStatus } from "@kan/db/schema"; import type { BoardVisibilityStatus } from "@kan/db/schema";
import { import {
boards, boards,
cardActivities,
cards, cards,
cardsToLabels, cardsToLabels,
cardToWorkspaceMembers, cardToWorkspaceMembers,
@@ -54,6 +55,7 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
const board = await db.query.boards.findFirst({ const board = await db.query.boards.findFirst({
columns: { columns: {
id: true, id: true,
type: true,
}, },
where: eq(boards.publicId, boardPublicId), where: eq(boards.publicId, boardPublicId),
}); });
@@ -707,6 +709,15 @@ export const createFromSnapshot = async (
if (!createdCard) throw new Error("Failed to create card"); if (!createdCard) throw new Error("Failed to create card");
// Create card.created activity
await tx.insert(cardActivities).values({
publicId: generateUID(),
type: "card.created",
cardId: createdCard.id,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
});
if (card.labels.length) { if (card.labels.length) {
const cardLabels: { cardId: number; labelId: number }[] = []; const cardLabels: { cardId: number; labelId: number }[] = [];
for (const label of card.labels) { for (const label of card.labels) {
@@ -714,8 +725,20 @@ export const createFromSnapshot = async (
if (newLabelId) if (newLabelId)
cardLabels.push({ cardId: createdCard.id, labelId: newLabelId }); cardLabels.push({ cardId: createdCard.id, labelId: newLabelId });
} }
if (cardLabels.length) if (cardLabels.length) {
await tx.insert(cardsToLabels).values(cardLabels); await tx.insert(cardsToLabels).values(cardLabels);
// Create card.updated.label.added activities for each label
const labelActivities = cardLabels.map((cardLabel) => ({
publicId: generateUID(),
type: "card.updated.label.added" as const,
cardId: cardLabel.cardId,
labelId: cardLabel.labelId,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
}));
await tx.insert(cardActivities).values(labelActivities);
}
} }
if (card.checklists?.length) { if (card.checklists?.length) {
@@ -736,6 +759,16 @@ export const createFromSnapshot = async (
if (!createdChecklist) continue; if (!createdChecklist) continue;
// Create card.updated.checklist.added activity
await tx.insert(cardActivities).values({
publicId: generateUID(),
type: "card.updated.checklist.added",
cardId: createdCard.id,
toTitle: checklist.name,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
});
if (checklist.items.length) { if (checklist.items.length) {
const itemValues = [...checklist.items] const itemValues = [...checklist.items]
.sort((a, b) => a.index - b.index) .sort((a, b) => a.index - b.index)
@@ -748,8 +781,20 @@ export const createFromSnapshot = async (
completed: !!checklistItem.completed, completed: !!checklistItem.completed,
})); }));
if (itemValues.length) if (itemValues.length) {
await tx.insert(checklistItems).values(itemValues); await tx.insert(checklistItems).values(itemValues);
// Create card.updated.checklist.item.added activities for each item
const itemActivities = itemValues.map((item) => ({
publicId: generateUID(),
type: "card.updated.checklist.item.added" as const,
cardId: createdCard.id,
toTitle: item.title,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
}));
await tx.insert(cardActivities).values(itemActivities);
}
} }
} }
} }

View File

@@ -22,6 +22,7 @@ export const create = async (
commentId?: number; commentId?: number;
fromComment?: string; fromComment?: string;
toComment?: string; toComment?: string;
sourceBoardId?: number;
}, },
) => { ) => {
const [result] = await db const [result] = await db
@@ -44,6 +45,7 @@ export const create = async (
commentId: activityInput.commentId, commentId: activityInput.commentId,
fromComment: activityInput.fromComment, fromComment: activityInput.fromComment,
toComment: activityInput.toComment, toComment: activityInput.toComment,
sourceBoardId: activityInput.sourceBoardId,
}) })
.returning({ id: cardActivities.id }); .returning({ id: cardActivities.id });
@@ -66,6 +68,7 @@ export const bulkCreate = async (
fromDescription?: string; fromDescription?: string;
toDescription?: string; toDescription?: string;
createdBy: string; createdBy: string;
sourceBoardId?: number;
}[], }[],
) => { ) => {
const activitiesWithPublicIds = activityInputs.map((activity) => ({ const activitiesWithPublicIds = activityInputs.map((activity) => ({

View File

@@ -12,6 +12,7 @@ import {
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { boards } from "./boards";
import { checklists } from "./checklists"; import { checklists } from "./checklists";
import { imports } from "./imports"; import { imports } from "./imports";
import { labels } from "./labels"; import { labels } from "./labels";
@@ -133,6 +134,10 @@ export const cardActivities = pgTable("card_activity", {
), ),
fromComment: text("fromComment"), fromComment: text("fromComment"),
toComment: text("toComment"), toComment: text("toComment"),
sourceBoardId: bigint("sourceBoardId", { mode: "number" }).references(
() => boards.id,
{ onDelete: "set null" },
),
}).enableRLS(); }).enableRLS();
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({