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;
|
boardPublicId: string;
|
||||||
boardName: string;
|
boardName: string;
|
||||||
listName: string;
|
listName: string;
|
||||||
|
cardNumber: number | null;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
type: "card";
|
type: "card";
|
||||||
@@ -159,8 +160,17 @@ export default function CommandPallette({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
<div className="flex items-center gap-2">
|
||||||
{result.title}
|
<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>
|
</div>
|
||||||
{result.type === "card" && (
|
{result.type === "card" && (
|
||||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Workspace {
|
|||||||
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
||||||
role: "admin" | "member" | "guest";
|
role: "admin" | "member" | "guest";
|
||||||
weekStartDay: 0 | 1 | 6;
|
weekStartDay: 0 | 1 | 6;
|
||||||
|
cardPrefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialWorkspace: Workspace = {
|
const initialWorkspace: Workspace = {
|
||||||
@@ -30,6 +31,7 @@ const initialWorkspace: Workspace = {
|
|||||||
plan: "free" as const,
|
plan: "free" as const,
|
||||||
role: "member",
|
role: "member",
|
||||||
weekStartDay: 1,
|
weekStartDay: 1,
|
||||||
|
cardPrefix: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
@@ -90,6 +92,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
description: workspace.description,
|
description: workspace.description,
|
||||||
plan: workspace.plan,
|
plan: workspace.plan,
|
||||||
weekStartDay: workspace.weekStartDay,
|
weekStartDay: workspace.weekStartDay,
|
||||||
|
cardPrefix: workspace.cardPrefix,
|
||||||
hasLoaded: true,
|
hasLoaded: true,
|
||||||
})) as Workspace[];
|
})) as Workspace[];
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
description: selectedWorkspace.workspace.description,
|
description: selectedWorkspace.workspace.description,
|
||||||
role: selectedWorkspace.role,
|
role: selectedWorkspace.role,
|
||||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||||
|
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workspacePublicId) {
|
if (workspacePublicId) {
|
||||||
@@ -141,6 +145,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
description: primaryWorkspace.description,
|
description: primaryWorkspace.description,
|
||||||
role: primaryWorkspaceRole,
|
role: primaryWorkspaceRole,
|
||||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||||
|
cardPrefix: primaryWorkspace.cardPrefix,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, isLoading, workspacePublicId, router]);
|
}, [data, isLoading, workspacePublicId, router]);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
|||||||
|
|
||||||
const Card = ({
|
const Card = ({
|
||||||
title,
|
title,
|
||||||
|
ticketNumber,
|
||||||
labels,
|
labels,
|
||||||
members,
|
members,
|
||||||
checklists,
|
checklists,
|
||||||
@@ -25,6 +26,7 @@ const Card = ({
|
|||||||
dueDate,
|
dueDate,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
|
ticketNumber?: string | null;
|
||||||
labels: { name: string; colourCode: string | null }[];
|
labels: { name: string; colourCode: string | null }[];
|
||||||
members: {
|
members: {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
@@ -67,6 +69,11 @@ const Card = ({
|
|||||||
|
|
||||||
return (
|
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">
|
<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>
|
<span className="break-words">{title}</span>
|
||||||
{labels.length ||
|
{labels.length ||
|
||||||
members.length ||
|
members.length ||
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export function NewCardForm({
|
|||||||
listId: 2,
|
listId: 2,
|
||||||
description: "",
|
description: "",
|
||||||
dueDate: args.dueDate ?? null,
|
dueDate: args.dueDate ?? null,
|
||||||
|
cardNumber: null,
|
||||||
|
comments: [],
|
||||||
|
checklists: [],
|
||||||
|
attachments: [],
|
||||||
labels: oldBoard.labels.filter((label) =>
|
labels: oldBoard.labels.filter((label) =>
|
||||||
args.labelPublicIds.includes(label.publicId),
|
args.labelPublicIds.includes(label.publicId),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -757,6 +757,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
title={card.title}
|
title={card.title}
|
||||||
|
ticketNumber={
|
||||||
|
card.cardNumber != null
|
||||||
|
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
labels={card.labels}
|
labels={card.labels}
|
||||||
members={card.members}
|
members={card.members}
|
||||||
checklists={card.checklists ?? []}
|
checklists={card.checklists ?? []}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import {
|
||||||
HiEllipsisHorizontal,
|
HiEllipsisHorizontal,
|
||||||
|
HiHashtag,
|
||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineCheckCircle,
|
HiOutlineCheckCircle,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
@@ -18,11 +19,13 @@ export default function CardDropdown({
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
cardCreatedBy,
|
cardCreatedBy,
|
||||||
|
ticketNumber,
|
||||||
}: {
|
}: {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
boardPublicId?: string;
|
boardPublicId?: string;
|
||||||
cardCreatedBy?: string | null;
|
cardCreatedBy?: string | null;
|
||||||
|
ticketNumber?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
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 = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: t`Copy card link`,
|
label: t`Copy card link`,
|
||||||
action: handleCopyCardLink,
|
action: handleCopyCardLink,
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
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
|
...(canEditCard
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
>
|
>
|
||||||
{board?.name}
|
{board?.name}
|
||||||
</Link>
|
</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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -347,6 +355,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isTemplate={isTemplate}
|
isTemplate={isTemplate}
|
||||||
boardPublicId={boardId}
|
boardPublicId={boardId}
|
||||||
cardCreatedBy={card?.createdBy}
|
cardCreatedBy={card?.createdBy}
|
||||||
|
ticketNumber={
|
||||||
|
card.cardNumber != null && card.list.board.workspace.cardPrefix
|
||||||
|
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ export function CardModal({
|
|||||||
</div>
|
</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]">
|
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
{data?.title}
|
{data?.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
listId: list.id,
|
listId: list.id,
|
||||||
|
workspaceId: list.workspaceId,
|
||||||
position: input.position,
|
position: input.position,
|
||||||
dueDate: input.dueDate ?? null,
|
dueDate: input.dueDate ?? null,
|
||||||
});
|
});
|
||||||
@@ -1272,6 +1273,7 @@ export const cardRouter = createTRPCRouter({
|
|||||||
description: sourceCard.description ?? "",
|
description: sourceCard.description ?? "",
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
listId: targetList.id,
|
listId: targetList.id,
|
||||||
|
workspaceId: targetList.workspaceId,
|
||||||
position: "end",
|
position: "end",
|
||||||
dueDate: sourceCard.dueDate ?? null,
|
dueDate: sourceCard.dueDate ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
description: card.description,
|
description: card.description,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
listId: newListId,
|
listId: newListId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
index,
|
index,
|
||||||
importId: newImportId,
|
importId: newImportId,
|
||||||
}));
|
}));
|
||||||
@@ -888,6 +889,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
listId: data.listId,
|
listId: data.listId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
index: index,
|
index: index,
|
||||||
importId: newImportId,
|
importId: newImportId,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
boardPublicId: z.string(),
|
boardPublicId: z.string(),
|
||||||
boardName: z.string(),
|
boardName: z.string(),
|
||||||
listName: z.string(),
|
listName: z.string(),
|
||||||
|
cardNumber: z.number().nullable(),
|
||||||
updatedAt: z.date().nullable(),
|
updatedAt: z.date().nullable(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
type: z.literal("card"),
|
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,
|
"when": 1775170588247,
|
||||||
"tag": "20260402225628_AddTeamWorkspacePlan",
|
"tag": "20260402225628_AddTeamWorkspacePlan",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776809379931,
|
||||||
|
"tag": "20260421220939_AddCardNumber",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -209,6 +209,7 @@ export const getByPublicId = async (
|
|||||||
workspace: {
|
workspace: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
cardPrefix: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
members: {
|
members: {
|
||||||
@@ -255,6 +256,7 @@ export const getByPublicId = async (
|
|||||||
listId: true,
|
listId: true,
|
||||||
index: true,
|
index: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
|
cardNumber: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
@@ -424,6 +426,7 @@ export const getBySlug = async (
|
|||||||
publicId: true,
|
publicId: true,
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
cardPrefix: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: {
|
labels: {
|
||||||
@@ -450,6 +453,7 @@ export const getBySlug = async (
|
|||||||
listId: true,
|
listId: true,
|
||||||
index: true,
|
index: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
|
cardNumber: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
labels,
|
labels,
|
||||||
lists,
|
lists,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
|
workspaces,
|
||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const create = async (
|
|||||||
description: string;
|
description: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
listId: number;
|
listId: number;
|
||||||
|
workspaceId: number;
|
||||||
position: "start" | "end";
|
position: "start" | "end";
|
||||||
dueDate?: Date | null;
|
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
|
const result = await tx
|
||||||
.insert(cards)
|
.insert(cards)
|
||||||
.values({
|
.values({
|
||||||
@@ -91,9 +104,10 @@ export const create = async (
|
|||||||
createdBy: cardInput.createdBy,
|
createdBy: cardInput.createdBy,
|
||||||
listId: cardInput.listId,
|
listId: cardInput.listId,
|
||||||
index: index,
|
index: index,
|
||||||
|
cardNumber,
|
||||||
dueDate: cardInput.dueDate ?? null,
|
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");
|
if (!result[0]) throw new Error("Unable to create card");
|
||||||
|
|
||||||
@@ -273,6 +287,7 @@ export const bulkCreate = async (
|
|||||||
description: string;
|
description: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
listId: number;
|
listId: number;
|
||||||
|
workspaceId: number;
|
||||||
index: number;
|
index: number;
|
||||||
importId?: number;
|
importId?: number;
|
||||||
}[],
|
}[],
|
||||||
@@ -288,6 +303,34 @@ export const bulkCreate = async (
|
|||||||
byList.set(item.listId, arr);
|
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: {
|
const allValuesToInsert: {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -295,6 +338,7 @@ export const bulkCreate = async (
|
|||||||
createdBy: string;
|
createdBy: string;
|
||||||
listId: number;
|
listId: number;
|
||||||
index: number;
|
index: number;
|
||||||
|
cardNumber: number;
|
||||||
importId?: number;
|
importId?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
@@ -309,6 +353,12 @@ export const bulkCreate = async (
|
|||||||
let nextIndex = last ? last.index + 1 : 0;
|
let nextIndex = last ? last.index + 1 : 0;
|
||||||
const sorted = [...items].sort((a, b) => a.index - b.index);
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||||
for (const it of sorted) {
|
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({
|
allValuesToInsert.push({
|
||||||
publicId: it.publicId,
|
publicId: it.publicId,
|
||||||
title: it.title,
|
title: it.title,
|
||||||
@@ -316,6 +366,7 @@ export const bulkCreate = async (
|
|||||||
createdBy: it.createdBy,
|
createdBy: it.createdBy,
|
||||||
listId: it.listId,
|
listId: it.listId,
|
||||||
index: nextIndex++,
|
index: nextIndex++,
|
||||||
|
cardNumber,
|
||||||
importId: it.importId,
|
importId: it.importId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -434,6 +485,7 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
description: true,
|
description: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
createdBy: true,
|
createdBy: true,
|
||||||
|
cardNumber: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
@@ -510,6 +562,7 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
workspace: {
|
workspace: {
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
|
cardPrefix: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
members: {
|
members: {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
|
asc,
|
||||||
count,
|
count,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
ilike,
|
ilike,
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
asc,
|
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import type { Permission, Role } from "@kan/shared";
|
||||||
import {
|
import {
|
||||||
boards,
|
boards,
|
||||||
cards,
|
cards,
|
||||||
@@ -19,8 +20,11 @@ import {
|
|||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
workspaces,
|
workspaces,
|
||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import type { Permission, Role } from "@kan/shared";
|
import {
|
||||||
import { generateUID, getDefaultPermissions } from "@kan/shared";
|
generateUID,
|
||||||
|
generateWorkspacePrefix,
|
||||||
|
getDefaultPermissions,
|
||||||
|
} from "@kan/shared";
|
||||||
|
|
||||||
import * as permissionRepo from "./permission.repo";
|
import * as permissionRepo from "./permission.repo";
|
||||||
|
|
||||||
@@ -30,22 +34,22 @@ const SYSTEM_ROLES: {
|
|||||||
description: string;
|
description: string;
|
||||||
hierarchyLevel: number;
|
hierarchyLevel: number;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
name: "admin",
|
name: "admin",
|
||||||
description: "Full access to all workspace features",
|
description: "Full access to all workspace features",
|
||||||
hierarchyLevel: 100,
|
hierarchyLevel: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "member",
|
name: "member",
|
||||||
description: "Standard member with create and edit permissions",
|
description: "Standard member with create and edit permissions",
|
||||||
hierarchyLevel: 50,
|
hierarchyLevel: 50,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "guest",
|
name: "guest",
|
||||||
description: "View-only access",
|
description: "View-only access",
|
||||||
hierarchyLevel: 10,
|
hierarchyLevel: 10,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
export const getCount = async (db: dbClient) => {
|
||||||
const result = await db
|
const result = await db
|
||||||
@@ -75,8 +79,12 @@ export const create = async (
|
|||||||
name: workspaceInput.name,
|
name: workspaceInput.name,
|
||||||
slug: workspaceInput.slug,
|
slug: workspaceInput.slug,
|
||||||
createdBy: workspaceInput.createdBy,
|
createdBy: workspaceInput.createdBy,
|
||||||
...(workspaceInput.description && { description: workspaceInput.description }),
|
...(workspaceInput.description && {
|
||||||
|
description: workspaceInput.description,
|
||||||
|
}),
|
||||||
...(workspaceInput.plan && { plan: workspaceInput.plan }),
|
...(workspaceInput.plan && { plan: workspaceInput.plan }),
|
||||||
|
cardPrefix: generateWorkspacePrefix(workspaceInput.name),
|
||||||
|
cardCounter: 0,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: workspaces.id,
|
id: workspaces.id,
|
||||||
@@ -258,8 +266,12 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
|
|||||||
slug: true,
|
slug: true,
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public"), eq(boards.isArchived, false)),
|
where: and(
|
||||||
orderBy: [asc(boards.name)]
|
isNull(boards.deletedAt),
|
||||||
|
eq(boards.visibility, "public"),
|
||||||
|
eq(boards.isArchived, false),
|
||||||
|
),
|
||||||
|
orderBy: [asc(boards.name)],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
@@ -283,6 +295,7 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
|
|||||||
slug: true,
|
slug: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
weekStartDay: true,
|
weekStartDay: true,
|
||||||
|
cardPrefix: true,
|
||||||
deletedAt: true,
|
deletedAt: true,
|
||||||
},
|
},
|
||||||
// https://github.com/drizzle-team/drizzle-orm/issues/2903
|
// https://github.com/drizzle-team/drizzle-orm/issues/2903
|
||||||
@@ -365,6 +378,14 @@ export const isUserInWorkspace = async (
|
|||||||
return result?.id !== undefined;
|
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 (
|
export const searchBoardsAndCards = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
@@ -373,6 +394,8 @@ export const searchBoardsAndCards = async (
|
|||||||
) => {
|
) => {
|
||||||
const searchQuery = `%${query}%`;
|
const searchQuery = `%${query}%`;
|
||||||
|
|
||||||
|
const ticketId = parseTicketId(query.trim());
|
||||||
|
|
||||||
// Search for boards
|
// Search for boards
|
||||||
const boardResults = await db
|
const boardResults = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -400,25 +423,19 @@ export const searchBoardsAndCards = async (
|
|||||||
sql`similarity(${boards.name}, ${query}) DESC`,
|
sql`similarity(${boards.name}, ${query}) DESC`,
|
||||||
desc(boards.updatedAt),
|
desc(boards.updatedAt),
|
||||||
)
|
)
|
||||||
.limit(Math.ceil(limit * 0.4));
|
.limit(ticketId ? 0 : Math.ceil(limit * 0.4));
|
||||||
|
|
||||||
// Search for cards
|
// Search for cards by ticket ID or by title
|
||||||
const cardResults = await db
|
const cardWhereConditions = ticketId
|
||||||
.select({
|
? and(
|
||||||
publicId: cards.publicId,
|
eq(boards.workspaceId, workspaceId),
|
||||||
title: cards.title,
|
eq(cards.cardNumber, ticketId.number),
|
||||||
description: cards.description,
|
ilike(workspaces.cardPrefix, ticketId.prefix),
|
||||||
boardPublicId: boards.publicId,
|
isNull(cards.deletedAt),
|
||||||
boardName: boards.name,
|
isNull(lists.deletedAt),
|
||||||
listName: lists.name,
|
isNull(boards.deletedAt),
|
||||||
updatedAt: cards.updatedAt,
|
)
|
||||||
createdAt: cards.createdAt,
|
: and(
|
||||||
})
|
|
||||||
.from(cards)
|
|
||||||
.innerJoin(lists, eq(cards.listId, lists.id))
|
|
||||||
.innerJoin(boards, eq(lists.boardId, boards.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(boards.workspaceId, workspaceId),
|
eq(boards.workspaceId, workspaceId),
|
||||||
or(
|
or(
|
||||||
ilike(cards.title, searchQuery),
|
ilike(cards.title, searchQuery),
|
||||||
@@ -427,19 +444,46 @@ export const searchBoardsAndCards = async (
|
|||||||
isNull(cards.deletedAt),
|
isNull(cards.deletedAt),
|
||||||
isNull(lists.deletedAt),
|
isNull(lists.deletedAt),
|
||||||
isNull(boards.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(
|
.orderBy(
|
||||||
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
|
...(ticketId
|
||||||
sql`similarity(${cards.title}, ${query}) DESC`,
|
? [desc(cards.createdAt)]
|
||||||
desc(cards.updatedAt),
|
: [
|
||||||
|
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
|
// Combine results
|
||||||
const allResults = [
|
const allResults = [
|
||||||
...boardResults.map((board) => ({ ...board, type: "board" as const })),
|
...boardResults.map((board) => ({
|
||||||
...cardResults.map((card) => ({ ...card, type: "card" as const })),
|
...board,
|
||||||
|
type: "board" as const,
|
||||||
|
})),
|
||||||
|
...cardResults.map((card) => ({
|
||||||
|
...card,
|
||||||
|
type: "card" as const,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Ensure we don't exceed the total limit
|
// Ensure we don't exceed the total limit
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
bigserial,
|
bigserial,
|
||||||
|
index,
|
||||||
integer,
|
integer,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -54,27 +55,36 @@ export type ActivityType = (typeof activityTypes)[number];
|
|||||||
|
|
||||||
export const activityTypeEnum = pgEnum("card_activity_type", activityTypes);
|
export const activityTypeEnum = pgEnum("card_activity_type", activityTypes);
|
||||||
|
|
||||||
export const cards = pgTable("card", {
|
export const cards = pgTable(
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
"card",
|
||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
{
|
||||||
title: text("title").notNull(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
description: text("description"),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
index: integer("index").notNull(),
|
title: text("title").notNull(),
|
||||||
createdBy: uuid("createdBy").references(() => users.id, {
|
description: text("description"),
|
||||||
onDelete: "set null",
|
index: integer("index").notNull(),
|
||||||
}),
|
cardNumber: integer("cardNumber"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
updatedAt: timestamp("updatedAt"),
|
onDelete: "set null",
|
||||||
deletedAt: timestamp("deletedAt"),
|
}),
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
onDelete: "set null",
|
updatedAt: timestamp("updatedAt"),
|
||||||
}),
|
deletedAt: timestamp("deletedAt"),
|
||||||
listId: bigint("listId", { mode: "number" })
|
deletedBy: uuid("deletedBy").references(() => users.id, {
|
||||||
.notNull()
|
onDelete: "set null",
|
||||||
.references(() => lists.id, { onDelete: "cascade" }),
|
}),
|
||||||
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
listId: bigint("listId", { mode: "number" })
|
||||||
dueDate: timestamp("dueDate"),
|
.notNull()
|
||||||
}).enableRLS();
|
.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 }) => ({
|
export const cardsRelations = relations(cards, ({ one, many }) => ({
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
bigint,
|
bigint,
|
||||||
bigserial,
|
bigserial,
|
||||||
boolean,
|
boolean,
|
||||||
|
index,
|
||||||
integer,
|
integer,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -38,25 +39,31 @@ export const workspacePlans = ["free", "team", "pro", "enterprise"] as const;
|
|||||||
export type WorkspacePlan = (typeof workspacePlans)[number];
|
export type WorkspacePlan = (typeof workspacePlans)[number];
|
||||||
export const workspacePlanEnum = pgEnum("workspace_plan", workspacePlans);
|
export const workspacePlanEnum = pgEnum("workspace_plan", workspacePlans);
|
||||||
|
|
||||||
export const workspaces = pgTable("workspace", {
|
export const workspaces = pgTable(
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
"workspace",
|
||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
{
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
description: text("description"),
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
description: text("description"),
|
||||||
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
weekStartDay: integer("weekStartDay").notNull().default(1),
|
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||||
createdBy: uuid("createdBy").references(() => users.id, {
|
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
||||||
onDelete: "set null",
|
weekStartDay: integer("weekStartDay").notNull().default(1),
|
||||||
}),
|
cardPrefix: varchar("cardPrefix", { length: 10 }).notNull().default(""),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
cardCounter: integer("cardCounter").notNull().default(0),
|
||||||
updatedAt: timestamp("updatedAt"),
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
deletedAt: timestamp("deletedAt"),
|
onDelete: "set null",
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id, {
|
}),
|
||||||
onDelete: "set null",
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
}),
|
updatedAt: timestamp("updatedAt"),
|
||||||
}).enableRLS();
|
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 }) => ({
|
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||||
user: one(users, {
|
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 "./generateUID";
|
||||||
export * from "./generateSlug";
|
export * from "./generateSlug";
|
||||||
|
export * from "./generateWorkspacePrefix";
|
||||||
export * from "./subscriptions";
|
export * from "./subscriptions";
|
||||||
export * from "./email";
|
export * from "./email";
|
||||||
export * from "./dueDateFilters";
|
export * from "./dueDateFilters";
|
||||||
|
|||||||
Reference in New Issue
Block a user