Files
kan/packages/db/src/repository/board.repo.ts
Eliott Herbert-Byrnes af36fb133a feat: archived boards (#386)
* init: schema migration, isArchived added to
board table. refactor: board repo for isArchived filtering

* init: archived, unarchived API procedures. refactor: all query

* fix: migration error

* feat: add tabbed navigation for boards view

* Implemented a Listbox for mobile and a tabbed navigation for desktop to switch between "Boards" and "Archived" views.
* Introduced state management for active tab selection.
* Updated UI components to reflect the new navigation structure.

* init: frontend/boards lists & tabs

* chore: fixed font styling and spacing

* init:boardDropdown / boardView.

* chore:added translations

* Remove .cursor plan file from repo

* fix:build erros

* revert: remove locales changes

* fix:reverted changes under locales, replaced the archive and unarchive endpoints. Reorder migrations

* fix:migration issue

* fix: update journal.json

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-26 21:39:15 +00:00

995 lines
25 KiB
TypeScript

import {
and,
asc,
count,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
lt,
or,
} from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { BoardVisibilityStatus } from "@kan/db/schema";
import {
boards,
cardActivities,
cardAttachments,
cards,
cardsToLabels,
cardToWorkspaceMembers,
checklistItems,
checklists,
comments,
labels,
lists,
userBoardFavorites,
workspaceMembers,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const getCount = async (db: dbClient) => {
const result = await db
.select({ count: count() })
.from(boards)
.where(isNull(boards.deletedAt));
return result[0]?.count ?? 0;
};
export const getAllByWorkspaceId = async (
db: dbClient,
workspaceId: number,
userId: string,
opts?: { type?: "regular" | "template"; archived?: boolean },
) => {
const boardsData = await db.query.boards.findMany({
columns: {
publicId: true,
name: true,
},
with: {
userFavorites: {
where: eq(userBoardFavorites.userId, userId),
columns: {
userId: true,
},
},
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,
opts?.archived !== undefined ? eq(boards.isArchived, opts.archived) : undefined,
),
});
// Transform and sort: favorites first, then alphabetically
return boardsData
.map((board) => ({
...board,
favorite: board.userFavorites.length > 0,
userFavorites: undefined,
}))
.sort((a, b) => {
// Sort favorites first
if (a.favorite && !b.favorite) return -1;
if (!a.favorite && b.favorite) return 1;
// Then alphabetically by name
return a.name.localeCompare(b.name);
});
};
export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
const board = await db.query.boards.findFirst({
columns: {
id: true,
type: true,
isArchived: true,
},
where: eq(boards.publicId, boardPublicId),
});
return board;
};
interface DueDateFilter {
startDate?: Date;
endDate?: Date;
hasNoDueDate?: boolean;
}
const buildDueDateWhere = (filters: DueDateFilter[]) => {
if (!filters.length) return undefined;
const clauses = filters
.map((filter) => {
const conditions: ReturnType<typeof and>[] = [];
if (filter.hasNoDueDate) {
conditions.push(isNull(cards.dueDate));
} else {
conditions.push(isNotNull(cards.dueDate));
if (filter.startDate)
conditions.push(gte(cards.dueDate, filter.startDate));
if (filter.endDate) conditions.push(lt(cards.dueDate, filter.endDate));
}
return conditions.length > 0 ? and(...conditions) : undefined;
})
.filter((clause): clause is NonNullable<typeof clause> => !!clause);
if (!clauses.length) return undefined;
return or(...clauses);
};
export const getByPublicId = async (
db: dbClient,
boardPublicId: string,
userId: string,
filters: {
members: string[];
labels: string[];
lists: string[];
dueDate: DueDateFilter[];
type: "regular" | "template" | undefined;
},
) => {
let cardIds: string[] = [];
if (filters.labels.length > 0 || filters.members.length > 0) {
const filteredCards = await db
.select({
publicId: cards.publicId,
})
.from(cards)
.leftJoin(cardsToLabels, eq(cards.id, cardsToLabels.cardId))
.leftJoin(labels, eq(cardsToLabels.labelId, labels.id))
.leftJoin(
cardToWorkspaceMembers,
eq(cards.id, cardToWorkspaceMembers.cardId),
)
.leftJoin(
workspaceMembers,
eq(cardToWorkspaceMembers.workspaceMemberId, workspaceMembers.id),
)
.where(
and(
isNull(cards.deletedAt),
or(
filters.labels.length > 0
? inArray(labels.publicId, filters.labels)
: undefined,
filters.members.length > 0
? inArray(workspaceMembers.publicId, filters.members)
: undefined,
),
),
);
cardIds = filteredCards.map((card) => card.publicId);
}
const board = await db.query.boards.findFirst({
columns: {
publicId: true,
name: true,
slug: true,
visibility: true,
isArchived: true,
},
with: {
userFavorites: {
where: eq(userBoardFavorites.userId, userId),
columns: {
userId: true,
},
},
workspace: {
columns: {
publicId: true,
},
with: {
members: {
columns: {
publicId: true,
email: true,
status: true,
},
with: {
user: {
columns: {
name: true,
email: true,
image: true,
},
},
},
where: isNull(workspaceMembers.deletedAt),
},
},
},
labels: {
columns: {
publicId: true,
name: true,
colourCode: true,
},
where: isNull(labels.deletedAt),
},
lists: {
columns: {
publicId: true,
name: true,
boardId: true,
index: true,
},
with: {
cards: {
columns: {
publicId: true,
title: true,
description: true,
listId: true,
index: true,
dueDate: true,
},
with: {
labels: {
with: {
label: {
columns: {
publicId: true,
name: true,
colourCode: true,
},
},
},
},
members: {
with: {
member: {
columns: {
publicId: true,
email: true,
deletedAt: true,
},
with: {
user: {
columns: {
name: true,
email: true,
image: true,
},
},
},
},
},
},
attachments: {
columns: {
publicId: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
comments: {
columns: {
publicId: true,
},
where: isNull(comments.deletedAt),
limit: 1,
},
},
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
isNull(cards.deletedAt),
buildDueDateWhere(filters.dueDate),
),
orderBy: [asc(cards.index)],
},
},
where: and(
isNull(lists.deletedAt),
filters.lists.length > 0
? inArray(lists.publicId, filters.lists)
: undefined,
),
orderBy: [asc(lists.index)],
},
allLists: {
columns: {
publicId: true,
name: true,
},
where: isNull(lists.deletedAt),
orderBy: [asc(lists.index)],
},
},
where: and(
eq(boards.publicId, boardPublicId),
isNull(boards.deletedAt),
eq(boards.type, filters.type ?? "regular"),
),
});
if (!board) return null;
const formattedResult = {
...board,
favorite: board.userFavorites.length > 0,
userFavorites: undefined,
lists: board.lists.map((list) => ({
...list,
cards: list.cards.map((card) => ({
...card,
labels: card.labels.map((label) => label.label),
members: card.members
.map((member) => member.member)
.filter((member) => member.deletedAt === null),
})),
})),
};
return formattedResult;
};
export const getBySlug = async (
db: dbClient,
boardSlug: string,
workspaceId: number,
filters: {
members: string[];
labels: string[];
lists: string[];
dueDate: DueDateFilter[];
},
) => {
let cardIds: string[] = [];
if (filters.labels.length) {
const filteredCards = await db
.select({
publicId: cards.publicId,
})
.from(cards)
.leftJoin(cardsToLabels, eq(cards.id, cardsToLabels.cardId))
.leftJoin(labels, eq(cardsToLabels.labelId, labels.id))
.where(
and(
isNull(cards.deletedAt),
filters.labels.length > 0
? inArray(labels.publicId, filters.labels)
: undefined,
),
);
cardIds = filteredCards.map((card) => card.publicId);
}
const board = await db.query.boards.findFirst({
columns: {
publicId: true,
name: true,
slug: true,
visibility: true,
},
with: {
workspace: {
columns: {
publicId: true,
name: true,
slug: true,
},
},
labels: {
columns: {
publicId: true,
name: true,
colourCode: true,
},
where: isNull(labels.deletedAt),
},
lists: {
columns: {
publicId: true,
name: true,
boardId: true,
index: true,
},
with: {
cards: {
columns: {
publicId: true,
title: true,
description: true,
listId: true,
index: true,
dueDate: true,
},
with: {
labels: {
with: {
label: {
columns: {
publicId: true,
name: true,
colourCode: true,
},
},
},
},
attachments: {
columns: {
publicId: true,
},
where: isNull(cardAttachments.deletedAt),
orderBy: asc(cardAttachments.createdAt),
},
comments: {
columns: {
publicId: true,
},
where: isNull(comments.deletedAt),
limit: 1,
},
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
},
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
isNull(cards.deletedAt),
buildDueDateWhere(filters.dueDate),
),
orderBy: [asc(cards.index)],
},
},
where: and(
isNull(lists.deletedAt),
filters.lists.length > 0
? inArray(lists.publicId, filters.lists)
: undefined,
),
orderBy: [asc(lists.index)],
},
allLists: {
columns: {
publicId: true,
name: true,
},
where: isNull(lists.deletedAt),
orderBy: [asc(lists.index)],
},
},
where: and(
eq(boards.slug, boardSlug),
eq(boards.workspaceId, workspaceId),
isNull(boards.deletedAt),
eq(boards.visibility, "public"),
),
});
if (!board) return null;
const formattedResult = {
...board,
lists: board.lists.map((list) => ({
...list,
cards: list.cards.map((card) => ({
...card,
labels: card.labels.map((label) => label.label),
})),
})),
};
return formattedResult;
};
export const getWithListIdsByPublicId = (
db: dbClient,
boardPublicId: string,
) => {
return db.query.boards.findFirst({
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
with: {
lists: {
columns: {
id: true,
},
},
},
where: eq(boards.publicId, boardPublicId),
});
};
export const getWithLatestListIndexByPublicId = (
db: dbClient,
boardPublicId: string,
) => {
return db.query.boards.findFirst({
columns: {
id: true,
workspaceId: true,
},
with: {
lists: {
columns: {
index: true,
},
where: isNull(lists.deletedAt),
orderBy: [desc(lists.index)],
limit: 1,
},
},
where: eq(boards.publicId, boardPublicId),
});
};
export const create = async (
db: dbClient,
boardInput: {
publicId?: string;
name: string;
createdBy: string;
workspaceId: number;
importId?: number;
slug: string;
type?: "regular" | "template";
sourceBoardId?: number;
},
) => {
const [result] = await db
.insert(boards)
.values({
publicId: boardInput.publicId ?? generateUID(),
name: boardInput.name,
createdBy: boardInput.createdBy,
workspaceId: boardInput.workspaceId,
importId: boardInput.importId,
slug: boardInput.slug,
type: boardInput.type ?? "regular",
sourceBoardId: boardInput.sourceBoardId,
})
.returning({
id: boards.id,
publicId: boards.publicId,
name: boards.name,
});
return result;
};
export const update = async (
db: dbClient,
boardInput: {
name: string | undefined;
slug: string | undefined;
visibility: BoardVisibilityStatus | undefined;
boardPublicId: string;
isArchived?: boolean;
},
) => {
const [result] = await db
.update(boards)
.set({
name: boardInput.name,
slug: boardInput.slug,
visibility: boardInput.visibility,
updatedAt: new Date(),
...(boardInput.isArchived !== undefined && { isArchived: boardInput.isArchived })
})
.where(eq(boards.publicId, boardInput.boardPublicId))
.returning({
publicId: boards.publicId,
name: boards.name,
});
return result;
};
export const softDelete = async (
db: dbClient,
args: {
boardId: number;
deletedAt: Date;
deletedBy: string;
},
) => {
const [result] = await db
.update(boards)
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.where(and(eq(boards.id, args.boardId), isNull(boards.deletedAt)))
.returning({
publicId: boards.publicId,
name: boards.name,
});
return result;
};
export const hardDelete = async (db: dbClient, workspaceId: number) => {
const [result] = await db
.delete(boards)
.where(eq(boards.workspaceId, workspaceId))
.returning({
publicId: boards.publicId,
name: boards.name,
});
return result;
};
export const isSlugUnique = async (
db: dbClient,
args: { slug: string; workspaceId: number },
) => {
const result = await db.query.boards.findFirst({
columns: {
slug: true,
},
where: and(
eq(boards.slug, args.slug),
eq(boards.workspaceId, args.workspaceId),
isNull(boards.deletedAt),
),
});
return result === undefined;
};
export const getWorkspaceAndBoardIdByBoardPublicId = async (
db: dbClient,
boardPublicId: string,
) => {
const result = await db.query.boards.findFirst({
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
where: eq(boards.publicId, boardPublicId),
});
return result;
};
export const isBoardSlugAvailable = async (
db: dbClient,
boardSlug: string,
workspaceId: number,
) => {
const result = await db.query.boards.findFirst({
columns: {
id: true,
},
where: and(
eq(boards.slug, boardSlug),
eq(boards.workspaceId, workspaceId),
isNull(boards.deletedAt),
),
});
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;
});
};
export const addUserFavorite = async (
db: dbClient,
userId: string,
boardId: number,
) => {
return db
.insert(userBoardFavorites)
.values({
userId,
boardId,
})
.onConflictDoNothing()
.returning();
};
export const removeUserFavorite = async (
db: dbClient,
userId: string,
boardId: number,
) => {
return db
.delete(userBoardFavorites)
.where(
and(
eq(userBoardFavorites.userId, userId),
eq(userBoardFavorites.boardId, boardId)
)
)
.returning();
};