feat: add ticket numbers to cards with workspace prefix (#443)
* feat: add ticket numbers to cards with workspace prefix - Add cardNumber to cards - Add cardPrefix/cardCounter to workspace - Populate initial prefixes and numbers via new migration - Introduce generateWorkspacePrefix util and export it - Include cardNumber in card-related API responses and search results - Render and display tickets as PREFIX-NUMBER in UI - Update Card component to accept ticketNumber - Update CardModal and board cards to show number when available - Add cardNumber to CommandPalette search results type * feat: regen migration * feat: enhance card and workspace schemas with cardNumber and indexing - Updated the card repository to allocate card numbers atomically per workspace. - Modified the card schema to include a cardNumber field and added an index on listId and cardNumber for improved query performance. - Enhanced the workspace schema to include an index on cardPrefix for optimized lookups. - Adjusted the migration journal to reflect the new schema changes and their timestamps. - Updated the regex in workspace repository to allow alphanumeric prefixes in ticket IDs. --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
committed by
GitHub
parent
bc8b5463b3
commit
4658b2961c
@@ -209,6 +209,7 @@ export const getByPublicId = async (
|
||||
workspace: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
cardPrefix: true,
|
||||
},
|
||||
with: {
|
||||
members: {
|
||||
@@ -255,6 +256,7 @@ export const getByPublicId = async (
|
||||
listId: true,
|
||||
index: true,
|
||||
dueDate: true,
|
||||
cardNumber: true,
|
||||
},
|
||||
with: {
|
||||
labels: {
|
||||
@@ -424,6 +426,7 @@ export const getBySlug = async (
|
||||
publicId: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
cardPrefix: true,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
@@ -450,6 +453,7 @@ export const getBySlug = async (
|
||||
listId: true,
|
||||
index: true,
|
||||
dueDate: true,
|
||||
cardNumber: true,
|
||||
},
|
||||
with: {
|
||||
labels: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
labels,
|
||||
lists,
|
||||
workspaceMembers,
|
||||
workspaces,
|
||||
} from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
@@ -41,6 +42,7 @@ export const create = async (
|
||||
description: string;
|
||||
createdBy: string;
|
||||
listId: number;
|
||||
workspaceId: number;
|
||||
position: "start" | "end";
|
||||
dueDate?: Date | null;
|
||||
},
|
||||
@@ -82,6 +84,17 @@ export const create = async (
|
||||
`);
|
||||
}
|
||||
|
||||
const [counterResult] = await tx
|
||||
.update(workspaces)
|
||||
.set({ cardCounter: sql`${workspaces.cardCounter} + 1` })
|
||||
.where(eq(workspaces.id, cardInput.workspaceId))
|
||||
.returning({ cardCounter: workspaces.cardCounter });
|
||||
|
||||
if (!counterResult)
|
||||
throw new Error(`Workspace ${cardInput.workspaceId} not found`);
|
||||
|
||||
const cardNumber = counterResult.cardCounter;
|
||||
|
||||
const result = await tx
|
||||
.insert(cards)
|
||||
.values({
|
||||
@@ -91,9 +104,10 @@ export const create = async (
|
||||
createdBy: cardInput.createdBy,
|
||||
listId: cardInput.listId,
|
||||
index: index,
|
||||
cardNumber,
|
||||
dueDate: cardInput.dueDate ?? null,
|
||||
})
|
||||
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId });
|
||||
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber });
|
||||
|
||||
if (!result[0]) throw new Error("Unable to create card");
|
||||
|
||||
@@ -273,6 +287,7 @@ export const bulkCreate = async (
|
||||
description: string;
|
||||
createdBy: string;
|
||||
listId: number;
|
||||
workspaceId: number;
|
||||
index: number;
|
||||
importId?: number;
|
||||
}[],
|
||||
@@ -288,6 +303,34 @@ export const bulkCreate = async (
|
||||
byList.set(item.listId, arr);
|
||||
}
|
||||
|
||||
// Atomically reserve a contiguous range of cardNumbers per workspace by
|
||||
// bumping cardCounter once per workspace.
|
||||
const countsByWorkspace = new Map<number, number>();
|
||||
for (const item of cardInput) {
|
||||
countsByWorkspace.set(
|
||||
item.workspaceId,
|
||||
(countsByWorkspace.get(item.workspaceId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const cardNumberByWorkspaceQueue = new Map<number, number[]>();
|
||||
for (const [workspaceId, count] of countsByWorkspace.entries()) {
|
||||
const [counterResult] = await tx
|
||||
.update(workspaces)
|
||||
.set({ cardCounter: sql`${workspaces.cardCounter} + ${count}` })
|
||||
.where(eq(workspaces.id, workspaceId))
|
||||
.returning({ cardCounter: workspaces.cardCounter });
|
||||
|
||||
if (!counterResult)
|
||||
throw new Error(`Workspace ${workspaceId} not found`);
|
||||
|
||||
const last = counterResult.cardCounter;
|
||||
const start = last - count + 1;
|
||||
const queue: number[] = [];
|
||||
for (let n = start; n <= last; n++) queue.push(n);
|
||||
cardNumberByWorkspaceQueue.set(workspaceId, queue);
|
||||
}
|
||||
|
||||
const allValuesToInsert: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
@@ -295,6 +338,7 @@ export const bulkCreate = async (
|
||||
createdBy: string;
|
||||
listId: number;
|
||||
index: number;
|
||||
cardNumber: number;
|
||||
importId?: number;
|
||||
}[] = [];
|
||||
|
||||
@@ -309,6 +353,12 @@ export const bulkCreate = async (
|
||||
let nextIndex = last ? last.index + 1 : 0;
|
||||
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||
for (const it of sorted) {
|
||||
const queue = cardNumberByWorkspaceQueue.get(it.workspaceId);
|
||||
const cardNumber = queue?.shift();
|
||||
if (cardNumber === undefined)
|
||||
throw new Error(
|
||||
`Failed to allocate cardNumber for workspace ${it.workspaceId}`,
|
||||
);
|
||||
allValuesToInsert.push({
|
||||
publicId: it.publicId,
|
||||
title: it.title,
|
||||
@@ -316,6 +366,7 @@ export const bulkCreate = async (
|
||||
createdBy: it.createdBy,
|
||||
listId: it.listId,
|
||||
index: nextIndex++,
|
||||
cardNumber,
|
||||
importId: it.importId,
|
||||
});
|
||||
}
|
||||
@@ -434,6 +485,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
description: true,
|
||||
dueDate: true,
|
||||
createdBy: true,
|
||||
cardNumber: true,
|
||||
},
|
||||
with: {
|
||||
labels: {
|
||||
@@ -510,6 +562,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
workspace: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
cardPrefix: true,
|
||||
},
|
||||
with: {
|
||||
members: {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
asc,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
import {
|
||||
boards,
|
||||
cards,
|
||||
@@ -19,8 +20,11 @@ import {
|
||||
workspaceMembers,
|
||||
workspaces,
|
||||
} from "@kan/db/schema";
|
||||
import type { Permission, Role } from "@kan/shared";
|
||||
import { generateUID, getDefaultPermissions } from "@kan/shared";
|
||||
import {
|
||||
generateUID,
|
||||
generateWorkspacePrefix,
|
||||
getDefaultPermissions,
|
||||
} from "@kan/shared";
|
||||
|
||||
import * as permissionRepo from "./permission.repo";
|
||||
|
||||
@@ -30,22 +34,22 @@ const SYSTEM_ROLES: {
|
||||
description: string;
|
||||
hierarchyLevel: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "admin",
|
||||
description: "Full access to all workspace features",
|
||||
hierarchyLevel: 100,
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
description: "Standard member with create and edit permissions",
|
||||
hierarchyLevel: 50,
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
description: "View-only access",
|
||||
hierarchyLevel: 10,
|
||||
},
|
||||
];
|
||||
{
|
||||
name: "admin",
|
||||
description: "Full access to all workspace features",
|
||||
hierarchyLevel: 100,
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
description: "Standard member with create and edit permissions",
|
||||
hierarchyLevel: 50,
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
description: "View-only access",
|
||||
hierarchyLevel: 10,
|
||||
},
|
||||
];
|
||||
|
||||
export const getCount = async (db: dbClient) => {
|
||||
const result = await db
|
||||
@@ -75,8 +79,12 @@ export const create = async (
|
||||
name: workspaceInput.name,
|
||||
slug: workspaceInput.slug,
|
||||
createdBy: workspaceInput.createdBy,
|
||||
...(workspaceInput.description && { description: workspaceInput.description }),
|
||||
...(workspaceInput.description && {
|
||||
description: workspaceInput.description,
|
||||
}),
|
||||
...(workspaceInput.plan && { plan: workspaceInput.plan }),
|
||||
cardPrefix: generateWorkspacePrefix(workspaceInput.name),
|
||||
cardCounter: 0,
|
||||
})
|
||||
.returning({
|
||||
id: workspaces.id,
|
||||
@@ -258,8 +266,12 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public"), eq(boards.isArchived, false)),
|
||||
orderBy: [asc(boards.name)]
|
||||
where: and(
|
||||
isNull(boards.deletedAt),
|
||||
eq(boards.visibility, "public"),
|
||||
eq(boards.isArchived, false),
|
||||
),
|
||||
orderBy: [asc(boards.name)],
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
@@ -283,6 +295,7 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
|
||||
slug: true,
|
||||
plan: true,
|
||||
weekStartDay: true,
|
||||
cardPrefix: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
// https://github.com/drizzle-team/drizzle-orm/issues/2903
|
||||
@@ -365,6 +378,14 @@ export const isUserInWorkspace = async (
|
||||
return result?.id !== undefined;
|
||||
};
|
||||
|
||||
const parseTicketId = (
|
||||
query: string,
|
||||
): { prefix: string; number: number } | null => {
|
||||
const match = /^([A-Za-z0-9]{1,10})-(\d+)$/.exec(query);
|
||||
if (!match) return null;
|
||||
return { prefix: match[1]!.toUpperCase(), number: parseInt(match[2]!, 10) };
|
||||
};
|
||||
|
||||
export const searchBoardsAndCards = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
@@ -373,6 +394,8 @@ export const searchBoardsAndCards = async (
|
||||
) => {
|
||||
const searchQuery = `%${query}%`;
|
||||
|
||||
const ticketId = parseTicketId(query.trim());
|
||||
|
||||
// Search for boards
|
||||
const boardResults = await db
|
||||
.select({
|
||||
@@ -400,25 +423,19 @@ export const searchBoardsAndCards = async (
|
||||
sql`similarity(${boards.name}, ${query}) DESC`,
|
||||
desc(boards.updatedAt),
|
||||
)
|
||||
.limit(Math.ceil(limit * 0.4));
|
||||
.limit(ticketId ? 0 : Math.ceil(limit * 0.4));
|
||||
|
||||
// Search for cards
|
||||
const cardResults = await db
|
||||
.select({
|
||||
publicId: cards.publicId,
|
||||
title: cards.title,
|
||||
description: cards.description,
|
||||
boardPublicId: boards.publicId,
|
||||
boardName: boards.name,
|
||||
listName: lists.name,
|
||||
updatedAt: cards.updatedAt,
|
||||
createdAt: cards.createdAt,
|
||||
})
|
||||
.from(cards)
|
||||
.innerJoin(lists, eq(cards.listId, lists.id))
|
||||
.innerJoin(boards, eq(lists.boardId, boards.id))
|
||||
.where(
|
||||
and(
|
||||
// Search for cards by ticket ID or by title
|
||||
const cardWhereConditions = ticketId
|
||||
? and(
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
eq(cards.cardNumber, ticketId.number),
|
||||
ilike(workspaces.cardPrefix, ticketId.prefix),
|
||||
isNull(cards.deletedAt),
|
||||
isNull(lists.deletedAt),
|
||||
isNull(boards.deletedAt),
|
||||
)
|
||||
: and(
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
or(
|
||||
ilike(cards.title, searchQuery),
|
||||
@@ -427,19 +444,46 @@ export const searchBoardsAndCards = async (
|
||||
isNull(cards.deletedAt),
|
||||
isNull(lists.deletedAt),
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
const cardResults = await db
|
||||
.select({
|
||||
publicId: cards.publicId,
|
||||
title: cards.title,
|
||||
description: cards.description,
|
||||
boardPublicId: boards.publicId,
|
||||
boardName: boards.name,
|
||||
listName: lists.name,
|
||||
cardNumber: cards.cardNumber,
|
||||
updatedAt: cards.updatedAt,
|
||||
createdAt: cards.createdAt,
|
||||
})
|
||||
.from(cards)
|
||||
.innerJoin(lists, eq(cards.listId, lists.id))
|
||||
.innerJoin(boards, eq(lists.boardId, boards.id))
|
||||
.innerJoin(workspaces, eq(boards.workspaceId, workspaces.id))
|
||||
.where(cardWhereConditions)
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
|
||||
sql`similarity(${cards.title}, ${query}) DESC`,
|
||||
desc(cards.updatedAt),
|
||||
...(ticketId
|
||||
? [desc(cards.createdAt)]
|
||||
: [
|
||||
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
|
||||
sql`similarity(${cards.title}, ${query}) DESC`,
|
||||
desc(cards.updatedAt),
|
||||
]),
|
||||
)
|
||||
.limit(Math.floor(limit * 0.6));
|
||||
.limit(ticketId ? limit : Math.floor(limit * 0.6));
|
||||
|
||||
// Combine results
|
||||
const allResults = [
|
||||
...boardResults.map((board) => ({ ...board, type: "board" as const })),
|
||||
...cardResults.map((card) => ({ ...card, type: "card" as const })),
|
||||
...boardResults.map((board) => ({
|
||||
...board,
|
||||
type: "board" as const,
|
||||
})),
|
||||
...cardResults.map((card) => ({
|
||||
...card,
|
||||
type: "card" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
// Ensure we don't exceed the total limit
|
||||
|
||||
@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
@@ -54,27 +55,36 @@ export type ActivityType = (typeof activityTypes)[number];
|
||||
|
||||
export const activityTypeEnum = pgEnum("card_activity_type", activityTypes);
|
||||
|
||||
export const cards = pgTable("card", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
index: integer("index").notNull(),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
listId: bigint("listId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => lists.id, { onDelete: "cascade" }),
|
||||
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
||||
dueDate: timestamp("dueDate"),
|
||||
}).enableRLS();
|
||||
export const cards = pgTable(
|
||||
"card",
|
||||
{
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
index: integer("index").notNull(),
|
||||
cardNumber: integer("cardNumber"),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
listId: bigint("listId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => lists.id, { onDelete: "cascade" }),
|
||||
importId: bigint("importId", { mode: "number" }).references(
|
||||
() => imports.id,
|
||||
),
|
||||
dueDate: timestamp("dueDate"),
|
||||
},
|
||||
(table) => [
|
||||
index("card_list_number_idx").on(table.listId, table.cardNumber),
|
||||
],
|
||||
).enableRLS();
|
||||
|
||||
export const cardsRelations = relations(cards, ({ one, many }) => ({
|
||||
createdBy: one(users, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
@@ -38,25 +39,31 @@ export const workspacePlans = ["free", "team", "pro", "enterprise"] as const;
|
||||
export type WorkspacePlan = (typeof workspacePlans)[number];
|
||||
export const workspacePlanEnum = pgEnum("workspace_plan", workspacePlans);
|
||||
|
||||
export const workspaces = pgTable("workspace", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
||||
weekStartDay: integer("weekStartDay").notNull().default(1),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
}).enableRLS();
|
||||
export const workspaces = pgTable(
|
||||
"workspace",
|
||||
{
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
||||
weekStartDay: integer("weekStartDay").notNull().default(1),
|
||||
cardPrefix: varchar("cardPrefix", { length: 10 }).notNull().default(""),
|
||||
cardCounter: integer("cardCounter").notNull().default(0),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
},
|
||||
(table) => [index("workspace_card_prefix_idx").on(table.cardPrefix)],
|
||||
).enableRLS();
|
||||
|
||||
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||
user: one(users, {
|
||||
|
||||
Reference in New Issue
Block a user