feat: add board favourites with animations (#318)

* feat: add board favorites with animations

* feat: add board favorites functionality with admin-only access

* refactor: make board favorites user-specific via junction table

* fix: assertUserInWorkspace

* Update pnpm lockfile to match package.json

* chore: clean up migrations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
Eliott Herbert-Byrnes
2026-02-01 22:29:51 +00:00
committed by GitHub
parent ec37f5480a
commit ef5bf87fdf
16 changed files with 6729 additions and 47 deletions

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "user_board_favorites" (
"userId" uuid NOT NULL,
"boardId" bigint NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_board_favorites_userId_boardId_pk" PRIMARY KEY("userId","boardId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_board_favorites" ADD CONSTRAINT "user_board_favorites_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_board_favorite_user_idx" ON "user_board_favorites" USING btree ("userId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_board_favorite_board_idx" ON "user_board_favorites" USING btree ("boardId");

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"id": "c24a8e89-c100-4483-9f0b-391201327ac5",
"prevId": "4e1ebb8b-bd52-48c5-87d6-8e6e5eb8bbde",
"prevId": "3b54938c-c8d2-41c1-b4ba-a377719891bc",
"version": "7",
"dialect": "postgresql",
"tables": {

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1769435949476,
"tag": "20260126135909_AddWorkspaceRoles",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1769983198190,
"tag": "20260201215958_AddUserBoardFavouritesTable",
"breakpoints": true
}
]
}

View File

@@ -26,6 +26,7 @@ import {
comments,
labels,
lists,
userBoardFavorites,
workspaceMembers,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
@@ -39,17 +40,24 @@ export const getCount = async (db: dbClient) => {
return result[0]?.count ?? 0;
};
export const getAllByWorkspaceId = (
export const getAllByWorkspaceId = async (
db: dbClient,
workspaceId: number,
userId: string,
opts?: { type?: "regular" | "template" },
) => {
return db.query.boards.findMany({
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,
@@ -72,6 +80,21 @@ export const getAllByWorkspaceId = (
opts?.type ? eq(boards.type, opts.type) : 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) => {
@@ -122,6 +145,7 @@ const buildDueDateWhere = (filters: DueDateFilter[]) => {
export const getByPublicId = async (
db: dbClient,
boardPublicId: string,
userId: string,
filters: {
members: string[];
labels: string[];
@@ -173,6 +197,12 @@ export const getByPublicId = async (
visibility: true,
},
with: {
userFavorites: {
where: eq(userBoardFavorites.userId, userId),
columns: {
userId: true,
},
},
workspace: {
columns: {
publicId: true,
@@ -325,6 +355,8 @@ export const getByPublicId = async (
const formattedResult = {
...board,
favorite: board.userFavorites.length > 0,
userFavorites: undefined,
lists: board.lists.map((list) => ({
...list,
cards: list.cards.map((card) => ({
@@ -924,3 +956,34 @@ export const createFromSnapshot = async (
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();
};

View File

@@ -6,6 +6,7 @@ import {
ilike,
inArray,
isNull,
asc,
or,
sql,
} from "drizzle-orm";
@@ -250,6 +251,7 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
name: true,
},
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public")),
orderBy: [asc(boards.name)]
},
},
where: and(

View File

@@ -2,9 +2,11 @@ import { relations, sql } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
@@ -67,6 +69,7 @@ export const boards = pgTable(
).enableRLS();
export const boardsRelations = relations(boards, ({ one, many }) => ({
userFavorites: many(userBoardFavorites),
createdBy: one(users, {
fields: [boards.createdBy],
references: [users.id],
@@ -91,3 +94,21 @@ export const boardsRelations = relations(boards, ({ one, many }) => ({
relationName: "boardWorkspace",
}),
}));
export const userBoardFavorites = pgTable(
"user_board_favorites",
{
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
boardId: bigint("boardId", { mode: "number" })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.boardId] }),
userIdx: index("user_board_favorite_user_idx").on(table.userId),
boardIdx: index("user_board_favorite_board_idx").on(table.boardId),
}),
);

View File

@@ -8,7 +8,7 @@ import {
} from "drizzle-orm/pg-core";
import { apikey } from "./auth";
import { boards } from "./boards";
import { boards, userBoardFavorites } from "./boards";
import { cards } from "./cards";
import { imports } from "./imports";
import { lists } from "./lists";
@@ -84,3 +84,14 @@ export const usersToWorkspacesRelations = relations(
}),
}),
);
export const userBoardFavoritesRelations = relations(userBoardFavorites, ({ one }) => ({
user: one(users, {
fields: [userBoardFavorites.userId],
references: [users.id],
}),
board: one(boards, {
fields: [userBoardFavorites.boardId],
references: [boards.id],
}),
}));