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
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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 ?? []}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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"),
|
||||
|
||||
47
packages/db/migrations/20260421220939_AddCardNumber.sql
Normal file
47
packages/db/migrations/20260421220939_AddCardNumber.sql
Normal 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);
|
||||
3927
packages/db/migrations/meta/20260421220939_snapshot.json
Normal file
3927
packages/db/migrations/meta/20260421220939_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,13 @@
|
||||
"when": 1775170588247,
|
||||
"tag": "20260402225628_AddTeamWorkspacePlan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1776809379931,
|
||||
"tag": "20260421220939_AddCardNumber",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
26
packages/shared/src/utils/generateWorkspacePrefix.ts
Normal file
26
packages/shared/src/utils/generateWorkspacePrefix.ts
Normal 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";
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./generateUID";
|
||||
export * from "./generateSlug";
|
||||
export * from "./generateWorkspacePrefix";
|
||||
export * from "./subscriptions";
|
||||
export * from "./email";
|
||||
export * from "./dueDateFilters";
|
||||
|
||||
Reference in New Issue
Block a user