feat: custom board templates (#98) (#209)

* feat: add type and sourceId columns to board schema

* chore: update _journal and add snapshot

* feat: scaffold templates page

* feat: add template pages

* feat: add template card page

* feat: add template view indicator

* fix: ensure checklists default to empty array in board view

* feat: create template from board

* feat: show custom templates in new board form

* feat: create card activity from snapshot creation

* chore: update readme and features

* chore: add translations
This commit is contained in:
Henry
2025-10-10 20:19:42 +01:00
committed by GitHub
parent 362381f516
commit a7a27e7054
41 changed files with 8296 additions and 1357 deletions

View File

@@ -25,7 +25,12 @@ export const boardRouter = createTRPCRouter({
protect: true,
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.input(
z.object({
workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
)
@@ -51,7 +56,9 @@ export const boardRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id);
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
type: input.type,
});
return result;
}),
@@ -71,6 +78,7 @@ export const boardRouter = createTRPCRouter({
boardPublicId: z.string().min(12),
members: z.array(z.string().min(12)).optional(),
labels: z.array(z.string().min(12)).optional(),
type: z.enum(["regular", "template"]).optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
@@ -102,6 +110,7 @@ export const boardRouter = createTRPCRouter({
{
members: input.members ?? [],
labels: input.labels ?? [],
type: input.type,
},
);
@@ -177,6 +186,8 @@ export const boardRouter = createTRPCRouter({
workspacePublicId: z.string().min(12),
lists: z.array(z.string().min(1)),
labels: z.array(z.string().min(1)),
type: z.enum(["regular", "template"]).optional(),
sourceBoardPublicId: z.string().min(12).optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
@@ -202,6 +213,79 @@ export const boardRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, workspace.id);
// If sourceBoardPublicId is provided, clone the source board
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(
ctx.db,
input.sourceBoardPublicId,
{
members: [],
labels: [],
type: sourceBoardInfo.type,
},
);
if (!sourceBoard)
throw new TRPCError({
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
code: "NOT_FOUND",
});
// Verify the source board belongs to the same workspace
const sourceWorkspace = await workspaceRepo.getByPublicId(
ctx.db,
sourceBoard.workspace.publicId,
);
if (!sourceWorkspace || sourceWorkspace.id !== workspace.id)
throw new TRPCError({
message: `Source board does not belong to this workspace`,
code: "FORBIDDEN",
});
let slug = generateSlug(input.name);
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
slug,
workspaceId: workspace.id,
});
if (!isSlugUnique || input.type === "template")
slug = `${slug}-${generateUID()}`;
const result = await boardRepo.createFromSnapshot(ctx.db, {
source: sourceBoard,
workspaceId: workspace.id,
createdBy: userId,
slug,
name: input.name,
type: input.type ?? "regular",
sourceBoardId: sourceBoardInfo.id,
});
if (!result)
throw new TRPCError({
message: `Failed to create board from source`,
code: "INTERNAL_SERVER_ERROR",
});
return result;
}
// Otherwise, create a new board with provided lists and labels
let slug = generateSlug(input.name);
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
@@ -209,7 +293,8 @@ export const boardRouter = createTRPCRouter({
workspaceId: workspace.id,
});
if (!isSlugUnique) slug = `${slug}-${generateUID()}`;
if (!isSlugUnique || input.type === "template")
slug = `${slug}-${generateUID()}`;
const result = await boardRepo.create(ctx.db, {
publicId: generateUID(),
@@ -217,6 +302,7 @@ export const boardRouter = createTRPCRouter({
name: input.name,
createdBy: userId,
workspaceId: workspace.id,
type: input.type,
});
if (!result)

View File

@@ -0,0 +1,5 @@
CREATE TYPE "public"."board_type" AS ENUM('regular', 'template');--> statement-breakpoint
ALTER TABLE "board" ADD COLUMN "type" "board_type" DEFAULT 'regular' NOT NULL;--> statement-breakpoint
ALTER TABLE "board" ADD COLUMN "sourceBoardId" bigint;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "board_type_idx" ON "board" USING btree ("type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "board_source_idx" ON "board" USING btree ("sourceBoardId");

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

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,20 @@
"when": 1759356096392,
"tag": "20251001220136_AddFuzzySearchSupport",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1759869689304,
"tag": "20251007204129_AddBoardTypeAndSourceIdColumns",
"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 {
boards,
cardActivities,
cards,
cardsToLabels,
cardToWorkspaceMembers,
@@ -15,13 +16,38 @@ import {
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const getAllByWorkspaceId = (db: dbClient, workspaceId: number) => {
export const getAllByWorkspaceId = (
db: dbClient,
workspaceId: number,
opts?: { type?: "regular" | "template" },
) => {
return db.query.boards.findMany({
columns: {
publicId: true,
name: true,
},
where: and(eq(boards.workspaceId, workspaceId), isNull(boards.deletedAt)),
with: {
lists: {
columns: {
publicId: true,
name: true,
index: true,
},
orderBy: [asc(lists.index)],
},
labels: {
columns: {
publicId: true,
name: true,
colourCode: true,
},
},
},
where: and(
eq(boards.workspaceId, workspaceId),
isNull(boards.deletedAt),
opts?.type ? eq(boards.type, opts.type) : undefined,
),
});
};
@@ -29,6 +55,7 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
const board = await db.query.boards.findFirst({
columns: {
id: true,
type: true,
},
where: eq(boards.publicId, boardPublicId),
});
@@ -42,6 +69,7 @@ export const getByPublicId = async (
filters: {
members: string[];
labels: string[];
type: "regular" | "template" | undefined;
},
) => {
let cardIds: string[] = [];
@@ -199,7 +227,11 @@ export const getByPublicId = async (
orderBy: [asc(lists.index)],
},
},
where: and(eq(boards.publicId, boardPublicId), isNull(boards.deletedAt)),
where: and(
eq(boards.publicId, boardPublicId),
isNull(boards.deletedAt),
eq(boards.type, filters.type ?? "regular"),
),
});
if (!board) return null;
@@ -412,6 +444,8 @@ export const create = async (
workspaceId: number;
importId?: number;
slug: string;
type?: "regular" | "template";
sourceBoardId?: number;
},
) => {
const [result] = await db
@@ -423,6 +457,8 @@ export const create = async (
workspaceId: boardInput.workspaceId,
importId: boardInput.importId,
slug: boardInput.slug,
type: boardInput.type ?? "regular",
sourceBoardId: boardInput.sourceBoardId,
})
.returning({
id: boards.id,
@@ -542,3 +578,229 @@ export const isBoardSlugAvailable = async (
return result === undefined;
};
// Create a new board (regular/template) from a full board snapshot
export const createFromSnapshot = async (
db: dbClient,
args: {
source: {
name: string;
labels: { publicId: string; name: string; colourCode: string | null }[];
lists: {
name: string;
index: number;
cards: {
title: string;
description: string | null;
index: number;
labels: {
publicId: string;
name: string;
colourCode: string | null;
}[];
checklists?: {
publicId: string;
name: string;
index: number;
items: {
publicId: string;
title: string;
completed: boolean;
index: number;
}[];
}[];
}[];
}[];
};
workspaceId: number;
createdBy: string;
slug: string;
name?: string;
type: "regular" | "template";
sourceBoardId?: number;
},
) => {
return db.transaction(async (tx) => {
const [newBoard] = await tx
.insert(boards)
.values({
publicId: generateUID(),
name: args.name ?? args.source.name,
slug: args.slug,
createdBy: args.createdBy,
workspaceId: args.workspaceId,
type: args.type,
sourceBoardId: args.sourceBoardId,
})
.returning({
id: boards.id,
publicId: boards.publicId,
name: boards.name,
});
if (!newBoard) throw new Error("Failed to create board");
// Labels
const srcLabels = args.source.labels;
const labelMap = new Map<string, number>();
if (srcLabels.length) {
const inserted = await tx
.insert(labels)
.values(
srcLabels.map((l) => ({
publicId: generateUID(),
name: l.name,
colourCode: l.colourCode ?? null,
createdBy: args.createdBy,
boardId: newBoard.id,
})),
)
.returning({ id: labels.id });
for (let i = 0; i < srcLabels.length; i++) {
const src = srcLabels[i];
if (!src) throw new Error("Source label not found");
const created = inserted[i];
if (created) labelMap.set(src.publicId, created.id);
}
}
// Lists
const listIndexToId = new Map<number, number>();
const srcLists = [...args.source.lists].sort((a, b) => a.index - b.index);
if (srcLists.length) {
const insertedLists = await tx
.insert(lists)
.values(
srcLists.map((list) => ({
publicId: generateUID(),
name: list.name,
createdBy: args.createdBy,
boardId: newBoard.id,
index: list.index,
})),
)
.returning({ id: lists.id, index: lists.index });
for (const list of insertedLists) listIndexToId.set(list.index, list.id);
}
// Cards, card-labels, checklists
for (const list of srcLists) {
const newListId = listIndexToId.get(list.index);
if (!newListId) continue;
const sortedCards = [...list.cards].sort((a, b) => a.index - b.index);
for (const card of sortedCards) {
const [createdCard] = await tx
.insert(cards)
.values({
publicId: generateUID(),
title: card.title,
description: card.description ?? "",
createdBy: args.createdBy,
listId: newListId,
index: card.index,
})
.returning({ id: cards.id });
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) {
const cardLabels: { cardId: number; labelId: number }[] = [];
for (const label of card.labels) {
const newLabelId = labelMap.get(label.publicId);
if (newLabelId)
cardLabels.push({ cardId: createdCard.id, labelId: newLabelId });
}
if (cardLabels.length) {
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) {
const sortedChecklists = [...card.checklists].sort(
(a, b) => a.index - b.index,
);
for (const checklist of sortedChecklists) {
const [createdChecklist] = await tx
.insert(checklists)
.values({
publicId: generateUID(),
name: checklist.name,
createdBy: args.createdBy,
cardId: createdCard.id,
index: checklist.index,
})
.returning({ id: checklists.id });
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) {
const itemValues = [...checklist.items]
.sort((a, b) => a.index - b.index)
.map((checklistItem) => ({
publicId: generateUID(),
title: checklistItem.title,
createdBy: args.createdBy,
checklistId: createdChecklist.id,
index: checklistItem.index,
completed: !!checklistItem.completed,
}));
if (itemValues.length) {
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);
}
}
}
}
}
}
return newBoard;
});
};

View File

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

View File

@@ -25,6 +25,10 @@ export const boardVisibilityEnum = pgEnum(
boardVisibilityStatuses,
);
export const boardTypes = ["regular", "template"] as const;
export type BoardType = (typeof boardTypes)[number];
export const boardTypeEnum = pgEnum("board_type", boardTypes);
export const boards = pgTable(
"board",
{
@@ -49,9 +53,13 @@ export const boards = pgTable(
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
type: boardTypeEnum("type").notNull().default("regular"),
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
},
(table) => [
index("board_visibility_idx").on(table.visibility),
index("board_type_idx").on(table.type),
index("board_source_idx").on(table.sourceBoardId),
uniqueIndex("unique_slug_per_workspace")
.on(table.workspaceId, table.slug)
.where(sql`${table.deletedAt} IS NULL`),

View File

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