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:
Florent Azonnoudo
2026-04-28 21:29:41 +00:00
committed by GitHub
parent bc8b5463b3
commit 4658b2961c
21 changed files with 4302 additions and 91 deletions

View File

@@ -33,6 +33,7 @@ type SearchResult =
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
@@ -159,8 +160,17 @@ export default function CommandPallette({
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
<div className="flex items-center gap-2">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" &&
result.cardNumber != null &&
workspace.cardPrefix && (
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
{workspace.cardPrefix}-{result.cardNumber}
</span>
)}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">

View File

@@ -20,6 +20,7 @@ interface Workspace {
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
cardPrefix: string;
}
const initialWorkspace: Workspace = {
@@ -30,6 +31,7 @@ const initialWorkspace: Workspace = {
plan: "free" as const,
role: "member",
weekStartDay: 1,
cardPrefix: "",
};
const initialAvailableWorkspaces: Workspace[] = [];
@@ -90,6 +92,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
cardPrefix: workspace.cardPrefix,
hasLoaded: true,
})) as Workspace[];
@@ -121,6 +124,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
@@ -141,6 +145,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
}
}, [data, isLoading, workspacePublicId, router]);

View File

@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
title,
ticketNumber,
labels,
members,
checklists,
@@ -25,6 +26,7 @@ const Card = ({
dueDate,
}: {
title: string;
ticketNumber?: string | null;
labels: { name: string; colourCode: string | null }[];
members: {
publicId: string;
@@ -67,6 +69,11 @@ const Card = ({
return (
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
{ticketNumber && (
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
{ticketNumber}
</span>
)}
<span className="break-words">{title}</span>
{labels.length ||
members.length ||

View File

@@ -146,6 +146,10 @@ export function NewCardForm({
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
cardNumber: null,
comments: [],
checklists: [],
attachments: [],
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),

View File

@@ -757,6 +757,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
<Card
title={card.title}
ticketNumber={
card.cardNumber != null
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
: null
}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
@@ -18,11 +19,13 @@ export default function CardDropdown({
isTemplate,
boardPublicId,
cardCreatedBy,
ticketNumber,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
@@ -53,12 +56,40 @@ export default function CardDropdown({
}
};
const handleCopyTicketId = async () => {
if (!ticketNumber) return;
try {
await navigator.clipboard.writeText(ticketNumber);
showPopup({
header: t`ID copied`,
icon: "success",
message: t`Ticket ID copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy ID`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(ticketNumber
? [
{
label: t`Copy ticket ID`,
action: handleCopyTicketId,
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(canEditCard
? [
{

View File

@@ -340,6 +340,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
<>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -347,6 +355,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -140,6 +140,11 @@ export function CardModal({
</div>
) : (
<>
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
</span>
)}
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>

View File

@@ -78,6 +78,7 @@ export const cardRouter = createTRPCRouter({
description: input.description,
createdBy: userId,
listId: list.id,
workspaceId: list.workspaceId,
position: input.position,
dueDate: input.dueDate ?? null,
});
@@ -1272,6 +1273,7 @@ export const cardRouter = createTRPCRouter({
description: sourceCard.description ?? "",
createdBy: userId,
listId: targetList.id,
workspaceId: targetList.workspaceId,
position: "end",
dueDate: sourceCard.dueDate ?? null,
});

View File

@@ -340,6 +340,7 @@ export const importRouter = createTRPCRouter({
description: card.description,
createdBy: userId,
listId: newListId,
workspaceId: workspace.id,
index,
importId: newImportId,
}));
@@ -888,6 +889,7 @@ export const importRouter = createTRPCRouter({
description: data.description,
createdBy: userId,
listId: data.listId,
workspaceId: workspace.id,
index: index,
importId: newImportId,
}));

View File

@@ -521,6 +521,7 @@ export const workspaceRouter = createTRPCRouter({
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
cardNumber: z.number().nullable(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"),

View File

@@ -0,0 +1,47 @@
ALTER TABLE "card" ADD COLUMN "cardNumber" integer;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "cardPrefix" varchar(10) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "cardCounter" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "card_list_number_idx" ON "card" USING btree ("listId","cardNumber");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_card_prefix_idx" ON "workspace" USING btree ("cardPrefix");--> statement-breakpoint
-- Populate cardPrefix for existing workspaces from their name
UPDATE "workspace"
SET "cardPrefix" = (
SELECT CASE
WHEN array_length(words, 1) = 1 THEN UPPER(LEFT(words[1], 3))
ELSE UPPER(LEFT(array_to_string(ARRAY(
SELECT LEFT(w, 1) FROM unnest(words) AS w WHERE w != ''
), ''), 4))
END
FROM (
SELECT regexp_split_to_array(trim("name"), '\s+') AS words
) sub
)
WHERE "cardPrefix" = '';--> statement-breakpoint
-- Assign sequential cardNumber to existing cards (including soft-deleted),
-- ordered by createdAt, scoped to workspace. Deleted cards still receive a
-- number so future un-archive/restore flows can keep their ticket ID.
WITH numbered AS (
SELECT
c.id,
ROW_NUMBER() OVER (PARTITION BY b."workspaceId" ORDER BY c."createdAt", c.id) AS rn
FROM "card" c
JOIN "list" l ON c."listId" = l.id
JOIN "board" b ON l."boardId" = b.id
WHERE c."cardNumber" IS NULL
)
UPDATE "card" c
SET "cardNumber" = n.rn
FROM numbered n
WHERE c.id = n.id;--> statement-breakpoint
-- Update cardCounter on each workspace to the max cardNumber assigned
UPDATE "workspace" w
SET "cardCounter" = COALESCE((
SELECT MAX(c."cardNumber")
FROM "card" c
JOIN "list" l ON c."listId" = l.id
JOIN "board" b ON l."boardId" = b.id
WHERE b."workspaceId" = w.id AND c."cardNumber" IS NOT NULL
), 0);

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,13 @@
"when": 1775170588247,
"tag": "20260402225628_AddTeamWorkspacePlan",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776809379931,
"tag": "20260421220939_AddCardNumber",
"breakpoints": true
}
]
}

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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

View File

@@ -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, {

View File

@@ -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, {

View File

@@ -0,0 +1,26 @@
export const generateWorkspacePrefix = (name: string): string => {
const words = name
.trim()
.split(/\s+/)
.filter((w) => w.length > 0);
if (words.length === 0) return "WS";
const firstWord = words[0];
if (words.length === 1 && firstWord) {
const word = firstWord.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
return word.slice(0, 3) || "WS";
}
const initials = words
.map((w) => {
const cleaned = w.replace(/[^a-zA-Z0-9]/g, "");
return cleaned.length > 0 ? cleaned[0]! : "";
})
.filter((c) => c.length > 0)
.join("")
.toUpperCase()
.slice(0, 4);
return initials || "WS";
};

View File

@@ -1,5 +1,6 @@
export * from "./generateUID";
export * from "./generateSlug";
export * from "./generateWorkspacePrefix";
export * from "./subscriptions";
export * from "./email";
export * from "./dueDateFilters";