* 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>
949 lines
28 KiB
TypeScript
949 lines
28 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
|
|
import * as boardRepo from "@kan/db/repository/board.repo";
|
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
|
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
|
import * as importRepo from "@kan/db/repository/import.repo";
|
|
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
|
import * as labelRepo from "@kan/db/repository/label.repo";
|
|
import * as listRepo from "@kan/db/repository/list.repo";
|
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|
import { colours } from "@kan/shared/constants";
|
|
import { generateSlug, generateUID } from "@kan/shared/utils";
|
|
|
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|
import { assertPermission } from "../utils/permissions";
|
|
import { assertUserInWorkspace } from "../utils/auth";
|
|
import { decryptToken } from "../utils/encryption";
|
|
import { apiKeys, urls } from "./integration";
|
|
|
|
export interface TrelloBoard {
|
|
id: string;
|
|
name: string;
|
|
labels: TrelloLabel[];
|
|
lists: TrelloList[];
|
|
cards: TrelloCard[];
|
|
checklists: TrelloChecklist[];
|
|
}
|
|
|
|
interface TrelloLabel {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface TrelloList {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface TrelloChecklist {
|
|
id: string;
|
|
idCard: string;
|
|
name: string;
|
|
checkItems: TrelloCheckItem[];
|
|
}
|
|
|
|
interface TrelloCheckItem {
|
|
id: string;
|
|
name: string;
|
|
state: "complete" | "incomplete";
|
|
pos: number;
|
|
}
|
|
|
|
interface GitHubProjectsResponse {
|
|
data: {
|
|
viewer: {
|
|
projectsV2: {
|
|
nodes?: { id: string; title: string }[];
|
|
};
|
|
organizations: {
|
|
nodes: {
|
|
projectsV2: {
|
|
nodes?: { id: string; title: string }[];
|
|
};
|
|
}[];
|
|
};
|
|
};
|
|
};
|
|
errors?: unknown[];
|
|
}
|
|
|
|
interface GitHubGraphQLResponse {
|
|
data?: {
|
|
node?: GitHubProjectV2Node;
|
|
};
|
|
errors?: unknown[];
|
|
}
|
|
|
|
interface GitHubProjectV2Node {
|
|
title: string;
|
|
field?: {
|
|
options?: { id: string; name: string }[];
|
|
};
|
|
areaField?: {
|
|
options?: { id: string; name: string; color: string }[];
|
|
};
|
|
items?: {
|
|
nodes: {
|
|
fieldValueByName?: { name: string };
|
|
areaValue?: { name: string };
|
|
content?: {
|
|
title?: string;
|
|
body?: string;
|
|
};
|
|
}[];
|
|
};
|
|
}
|
|
|
|
interface TrelloCard {
|
|
id: string;
|
|
name: string | null;
|
|
desc: string;
|
|
idList: string;
|
|
labels: TrelloLabel[];
|
|
idChecklists: string[];
|
|
checkItemStates: TrelloCheckItemState[];
|
|
}
|
|
|
|
interface TrelloCheckItemState {
|
|
idChecklist: string;
|
|
idCheckItem: string;
|
|
state: "complete" | "incomplete";
|
|
}
|
|
|
|
export const importRouter = createTRPCRouter({
|
|
trello: createTRPCRouter({
|
|
getBoards: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Get boards from Trello",
|
|
method: "GET",
|
|
path: "/integrations/trello/boards",
|
|
description: "Retrieves all boards from Trello",
|
|
tags: ["Integrations"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(z.void())
|
|
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
|
.query(async ({ ctx }) => {
|
|
const apiKey = apiKeys.trello;
|
|
|
|
if (!apiKey)
|
|
throw new TRPCError({
|
|
message: "Trello API key not found",
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
|
|
const user = ctx.user;
|
|
|
|
if (!user)
|
|
throw new TRPCError({
|
|
message: "User not authenticated",
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const integration = await integrationsRepo.getProviderForUser(
|
|
ctx.db,
|
|
user.id,
|
|
"trello",
|
|
);
|
|
|
|
const token = integration?.accessToken;
|
|
|
|
if (!token)
|
|
throw new TRPCError({
|
|
message: "Trello token not found",
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const response = await fetch(
|
|
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`,
|
|
);
|
|
|
|
const data = (await response.json()) as TrelloBoard[];
|
|
|
|
return data.map((board) => ({
|
|
id: board.id,
|
|
name: board.name,
|
|
}));
|
|
}),
|
|
importBoards: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Import boards from Trello",
|
|
method: "POST",
|
|
path: "/imports/trello/boards",
|
|
description: "Imports boards from Trello",
|
|
tags: ["Imports"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
boardIds: z.array(z.string()),
|
|
workspacePublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(z.object({ boardsCreated: z.number() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
const apiKey = apiKeys.trello;
|
|
|
|
if (!apiKey)
|
|
throw new TRPCError({
|
|
message: "Trello API key not found",
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const integration = await integrationsRepo.getProviderForUser(
|
|
ctx.db,
|
|
userId,
|
|
"trello",
|
|
);
|
|
|
|
if (!integration)
|
|
throw new TRPCError({
|
|
message: "Trello token not found",
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getByPublicId(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
await assertPermission(ctx.db, userId, workspace.id, "board:create");
|
|
|
|
const newImport = await importRepo.create(ctx.db, {
|
|
source: "trello",
|
|
createdBy: userId,
|
|
});
|
|
|
|
const newImportId = newImport?.id;
|
|
|
|
let boardsCreated = 0;
|
|
|
|
for (const boardId of input.boardIds) {
|
|
const response = await fetch(
|
|
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all&checklists=all&checkItemStates=all`,
|
|
);
|
|
const data = (await response.json()) as TrelloBoard;
|
|
|
|
const formattedData = {
|
|
name: data.name,
|
|
labels: data.labels
|
|
.map((label) => ({
|
|
sourceId: label.id,
|
|
name: label.name,
|
|
}))
|
|
.filter((_label) => !!_label.name),
|
|
lists: data.lists.map((list) => ({
|
|
name: list.name,
|
|
cards: data.cards
|
|
.filter((card) => card.idList === list.id)
|
|
.map((_card) => ({
|
|
sourceId: _card.id,
|
|
name: _card.name,
|
|
description: _card.desc,
|
|
labels: _card.labels.map((label) => ({
|
|
sourceId: label.id,
|
|
name: label.name,
|
|
})),
|
|
checklists: data.checklists
|
|
.filter((checklist) => checklist.idCard === _card.id)
|
|
.map((_checklist) => ({
|
|
sourceId: _checklist.id,
|
|
name: _checklist.name,
|
|
items: _checklist.checkItems.map((_item) => ({
|
|
sourceId: _item.id,
|
|
title: _item.name,
|
|
completed: _item.state === "complete",
|
|
index: _item.pos,
|
|
})),
|
|
})),
|
|
})),
|
|
})),
|
|
};
|
|
|
|
const boardPublicId = generateUID();
|
|
|
|
const newBoard = await boardRepo.create(ctx.db, {
|
|
publicId: boardPublicId,
|
|
name: formattedData.name,
|
|
slug: boardPublicId,
|
|
createdBy: userId,
|
|
importId: newImportId,
|
|
workspaceId: workspace.id,
|
|
});
|
|
|
|
const newBoardId = newBoard?.id;
|
|
|
|
if (!newBoardId)
|
|
throw new TRPCError({
|
|
message: "Failed to create new board",
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
|
|
let createdLabels: { id: number; sourceId: string }[] = [];
|
|
let createdCards: { id: number; sourceId: string }[] = [];
|
|
|
|
if (formattedData.labels.length) {
|
|
const labelsInsert = formattedData.labels.map((label, index) => ({
|
|
publicId: generateUID(),
|
|
name: label.name,
|
|
colourCode: colours[index % colours.length]?.code ?? "#0d9488",
|
|
createdBy: userId,
|
|
boardId: newBoardId,
|
|
importId: newImportId,
|
|
}));
|
|
|
|
const newLabels = await labelRepo.bulkCreate(ctx.db, labelsInsert);
|
|
|
|
if (newLabels.length)
|
|
createdLabels = newLabels
|
|
.map((label, index) => ({
|
|
id: label.id,
|
|
sourceId: formattedData.labels[index]?.sourceId ?? "",
|
|
}))
|
|
.filter((label) => !!label.sourceId);
|
|
}
|
|
|
|
for (const list of formattedData.lists) {
|
|
const newList = await listRepo.create(ctx.db, {
|
|
name: list.name,
|
|
createdBy: userId,
|
|
boardId: newBoardId,
|
|
importId: newImportId,
|
|
});
|
|
|
|
const newListId = newList.id;
|
|
|
|
if (list.cards.length && newListId) {
|
|
const cardsInsert = list.cards.map((card, index) => ({
|
|
publicId: generateUID(),
|
|
title: (card.name?.trim() ?? "Untitled Card").slice(0, 2000),
|
|
description: card.description,
|
|
createdBy: userId,
|
|
listId: newListId,
|
|
workspaceId: workspace.id,
|
|
index,
|
|
importId: newImportId,
|
|
}));
|
|
|
|
const newCards = await cardRepo.bulkCreate(ctx.db, cardsInsert);
|
|
|
|
if (!newCards.length)
|
|
throw new TRPCError({
|
|
message: "Failed to create new cards",
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
|
|
createdCards = createdCards.concat(
|
|
newCards
|
|
.map((card, index) => ({
|
|
id: card.id,
|
|
sourceId: list.cards[index]?.sourceId ?? "",
|
|
}))
|
|
.filter((card) => !!card.sourceId),
|
|
);
|
|
|
|
const activities = newCards.map((card) => ({
|
|
type: "card.created" as const,
|
|
cardId: card.id,
|
|
createdBy: userId,
|
|
}));
|
|
|
|
if (newCards.length > 0) {
|
|
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
|
}
|
|
|
|
const checklistsToCreate: {
|
|
cardId: number;
|
|
name: string;
|
|
createdBy: string;
|
|
index: number;
|
|
sourceId: string;
|
|
items: {
|
|
sourceId: string;
|
|
title: string;
|
|
completed: boolean;
|
|
index: number;
|
|
}[];
|
|
}[] = [];
|
|
|
|
for (const card of list.cards) {
|
|
const _card = createdCards.find(
|
|
(c) => c.sourceId === card.sourceId,
|
|
);
|
|
|
|
if (!_card || !card.checklists.length) continue;
|
|
|
|
for (
|
|
let checklistIndex = 0;
|
|
checklistIndex < card.checklists.length;
|
|
checklistIndex++
|
|
) {
|
|
const checklist = card.checklists[checklistIndex];
|
|
if (!checklist) continue;
|
|
|
|
checklistsToCreate.push({
|
|
cardId: _card.id,
|
|
name: checklist.name,
|
|
createdBy: userId,
|
|
index: checklistIndex,
|
|
sourceId: checklist.sourceId,
|
|
items: checklist.items.map((item) => ({
|
|
sourceId: item.sourceId,
|
|
title: item.title,
|
|
completed: item.completed,
|
|
index: item.index,
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (checklistsToCreate.length > 0) {
|
|
const newChecklists = await checklistRepo.bulkCreate(
|
|
ctx.db,
|
|
checklistsToCreate.map((checklist) => ({
|
|
cardId: checklist.cardId,
|
|
name: checklist.name,
|
|
createdBy: checklist.createdBy,
|
|
index: checklist.index,
|
|
})),
|
|
);
|
|
|
|
const itemsToCreate: {
|
|
checklistId: number;
|
|
title: string;
|
|
createdBy: string;
|
|
index: number;
|
|
completed: boolean;
|
|
}[] = [];
|
|
|
|
for (let i = 0; i < checklistsToCreate.length; i++) {
|
|
const checklistData = checklistsToCreate[i];
|
|
const newChecklist = newChecklists[i];
|
|
|
|
if (!newChecklist || !checklistData?.items.length) continue;
|
|
|
|
// NOTE: Sorting here to prevent checklist items being out of order
|
|
const sortedItems = [...checklistData.items].sort(
|
|
(a, b) => a.index - b.index,
|
|
);
|
|
|
|
for (const item of sortedItems) {
|
|
itemsToCreate.push({
|
|
checklistId: newChecklist.id,
|
|
title: item.title,
|
|
createdBy: userId,
|
|
index: item.index,
|
|
completed: item.completed,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (itemsToCreate.length > 0) {
|
|
await checklistRepo.bulkCreateItems(ctx.db, itemsToCreate);
|
|
}
|
|
}
|
|
|
|
if (createdLabels.length && createdCards.length) {
|
|
const cardLabelRelations: {
|
|
cardId: number;
|
|
labelId: number;
|
|
}[] = [];
|
|
|
|
for (const card of list.cards) {
|
|
const _card = createdCards.find(
|
|
(c) => c.sourceId === card.sourceId,
|
|
);
|
|
|
|
for (const label of card.labels) {
|
|
const _label = createdLabels.find(
|
|
(l) => l.sourceId === label.sourceId,
|
|
);
|
|
|
|
if (_card && _label) {
|
|
cardLabelRelations.push({
|
|
cardId: _card.id,
|
|
labelId: _label.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cardLabelRelations.length) {
|
|
await cardRepo.bulkCreateCardLabelRelationship(
|
|
ctx.db,
|
|
cardLabelRelations,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
boardsCreated++;
|
|
}
|
|
|
|
if (boardsCreated > 0 && newImportId) {
|
|
await importRepo.update(
|
|
ctx.db,
|
|
{ status: "success" },
|
|
{ importId: newImport.id },
|
|
);
|
|
}
|
|
|
|
return { boardsCreated };
|
|
}),
|
|
}),
|
|
github: createTRPCRouter({
|
|
getProjects: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Get projects from GitHub",
|
|
method: "GET",
|
|
path: "/integrations/github/projects",
|
|
description: "Retrieves all projects from GitHub",
|
|
tags: ["Integrations"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(z.void())
|
|
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
|
.query(async ({ ctx }) => {
|
|
const user = ctx.user;
|
|
|
|
if (!user)
|
|
throw new TRPCError({
|
|
message: "User not authenticated",
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const integration = await integrationsRepo.getProviderForUser(
|
|
ctx.db,
|
|
user.id,
|
|
"github",
|
|
);
|
|
|
|
if (!integration)
|
|
throw new TRPCError({
|
|
message: "GitHub token not found",
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const token = decryptToken(integration.accessToken);
|
|
|
|
// GraphQL query to fetch Projects V2 for the user and their organizations
|
|
const query = `
|
|
query {
|
|
viewer {
|
|
projectsV2(first: 20) {
|
|
nodes {
|
|
id
|
|
title
|
|
}
|
|
}
|
|
organizations(first: 10) {
|
|
nodes {
|
|
projectsV2(first: 10) {
|
|
nodes {
|
|
id
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await fetch("https://api.github.com/graphql", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `token ${token}`,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Kan-App",
|
|
},
|
|
body: JSON.stringify({ query }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(
|
|
`GitHub API Error: ${response.status} ${response.statusText}`,
|
|
);
|
|
console.error(`GitHub API Response: ${errorText}`);
|
|
|
|
throw new TRPCError({
|
|
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
}
|
|
|
|
const result = (await response.json()) as GitHubProjectsResponse;
|
|
|
|
if (result.errors) {
|
|
console.error("GitHub GraphQL Errors:", result.errors);
|
|
throw new TRPCError({
|
|
message: "Failed to fetch GitHub projects (GraphQL Error)",
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
}
|
|
|
|
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
|
|
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
|
|
(org) => org.projectsV2.nodes ?? [],
|
|
);
|
|
|
|
const allProjects = [...userProjects, ...orgProjects];
|
|
|
|
return allProjects.map((project) => ({
|
|
id: project.id,
|
|
name: project.title,
|
|
}));
|
|
}),
|
|
|
|
importProjects: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Import projects from GitHub",
|
|
method: "POST",
|
|
path: "/imports/github/projects",
|
|
description: "Imports projects from GitHub",
|
|
tags: ["Imports"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
projectIds: z.array(z.string()),
|
|
workspacePublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(z.object({ projectsImported: z.number() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
const integration = await integrationsRepo.getProviderForUser(
|
|
ctx.db,
|
|
userId,
|
|
"github",
|
|
);
|
|
|
|
if (!integration)
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "GitHub token not found",
|
|
});
|
|
|
|
const token = decryptToken(integration.accessToken);
|
|
|
|
const workspace = await workspaceRepo.getByPublicId(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Workspace not found",
|
|
});
|
|
|
|
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
|
|
|
const newImport = await importRepo.create(ctx.db, {
|
|
source: "github",
|
|
createdBy: userId,
|
|
});
|
|
|
|
if (!newImport) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to create import record",
|
|
});
|
|
}
|
|
|
|
const newImportId = newImport.id;
|
|
let projectsImported = 0;
|
|
|
|
for (const projectId of input.projectIds) {
|
|
// GraphQL query to fetch Project V2 details, status options, area options, and items
|
|
const query = `
|
|
query($id: ID!) {
|
|
node(id: $id) {
|
|
... on ProjectV2 {
|
|
title
|
|
field(name: "Status") {
|
|
... on ProjectV2SingleSelectField {
|
|
options {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
areaField: field(name: "Area") {
|
|
... on ProjectV2SingleSelectField {
|
|
options {
|
|
id
|
|
name
|
|
color
|
|
}
|
|
}
|
|
}
|
|
items(first: 100) {
|
|
nodes {
|
|
fieldValueByName(name: "Status") {
|
|
... on ProjectV2ItemFieldSingleSelectValue {
|
|
name
|
|
}
|
|
}
|
|
areaValue: fieldValueByName(name: "Area") {
|
|
... on ProjectV2ItemFieldSingleSelectValue {
|
|
name
|
|
}
|
|
}
|
|
content {
|
|
... on Issue {
|
|
title
|
|
body
|
|
}
|
|
... on PullRequest {
|
|
title
|
|
body
|
|
}
|
|
... on DraftIssue {
|
|
title
|
|
body
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await fetch("https://api.github.com/graphql", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `token ${token}`,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Kan-App",
|
|
},
|
|
body: JSON.stringify({ query, variables: { id: projectId } }),
|
|
});
|
|
|
|
const result = (await response.json()) as GitHubGraphQLResponse;
|
|
if (result.errors || !result.data?.node) continue;
|
|
|
|
const projectData = result.data.node;
|
|
const statusOptions = projectData.field?.options ?? [];
|
|
const areaOptions = projectData.areaField?.options ?? [];
|
|
const items = projectData.items?.nodes ?? [];
|
|
|
|
const boardPublicId = generateUID();
|
|
const board = await boardRepo.create(ctx.db, {
|
|
publicId: boardPublicId,
|
|
name: projectData.title,
|
|
workspaceId: workspace.id,
|
|
slug: generateSlug(projectData.title),
|
|
createdBy: userId,
|
|
importId: newImportId,
|
|
});
|
|
|
|
if (!board) continue;
|
|
|
|
// Prepare Labels
|
|
const labelsInsert = areaOptions.map((option) => {
|
|
let colourCode = "#0d9488"; // Default Teal
|
|
const ghColor = option.color;
|
|
|
|
// Map GitHub colors to Kan colors
|
|
if (ghColor === "BLUE") colourCode = "#0284c7";
|
|
else if (ghColor === "GREEN") colourCode = "#65a30d";
|
|
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
|
|
else if (ghColor === "ORANGE") colourCode = "#ea580c";
|
|
else if (ghColor === "RED") colourCode = "#dc2626";
|
|
else if (ghColor === "PINK") colourCode = "#db2777";
|
|
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
|
|
else if (ghColor === "GRAY") colourCode = "#0d9488";
|
|
|
|
return {
|
|
publicId: generateUID(),
|
|
name: option.name,
|
|
colourCode,
|
|
createdBy: userId,
|
|
boardId: board.id,
|
|
importId: newImportId,
|
|
};
|
|
});
|
|
|
|
const createdLabels = await labelRepo.bulkCreate(
|
|
ctx.db,
|
|
labelsInsert,
|
|
);
|
|
const labelMap = new Map<string, number>();
|
|
|
|
createdLabels.forEach((label, index) => {
|
|
const originalName = areaOptions[index]?.name;
|
|
if (originalName) {
|
|
labelMap.set(originalName, label.id);
|
|
}
|
|
});
|
|
|
|
// Prepare Lists
|
|
const listsInsert: {
|
|
publicId: string;
|
|
name: string;
|
|
createdBy: string;
|
|
boardId: number;
|
|
index: number;
|
|
importId: number;
|
|
}[] = [];
|
|
|
|
if (statusOptions.length === 0) {
|
|
listsInsert.push({
|
|
publicId: generateUID(),
|
|
name: "To Do",
|
|
createdBy: userId,
|
|
boardId: board.id,
|
|
index: 0,
|
|
importId: newImportId,
|
|
});
|
|
} else {
|
|
statusOptions.forEach((option, index) => {
|
|
listsInsert.push({
|
|
publicId: generateUID(),
|
|
name: option.name,
|
|
createdBy: userId,
|
|
boardId: board.id,
|
|
index: index,
|
|
importId: newImportId,
|
|
});
|
|
});
|
|
}
|
|
|
|
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
|
|
const listIdMap = new Map<string, number>();
|
|
createdLists.forEach((list, index) => {
|
|
const originalName = listsInsert[index]?.name;
|
|
if (originalName) {
|
|
listIdMap.set(originalName, list.id);
|
|
}
|
|
});
|
|
|
|
// Prepare Cards
|
|
const itemsToInsert: {
|
|
item: NonNullable<
|
|
NonNullable<
|
|
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
|
|
>[number]
|
|
>;
|
|
listId: number;
|
|
title: string;
|
|
description: string;
|
|
}[] = [];
|
|
|
|
for (const item of items) {
|
|
const statusName = item.fieldValueByName?.name;
|
|
const content = item.content ?? {};
|
|
const title = content.title ?? "Untitled Card";
|
|
const description = content.body ?? "";
|
|
|
|
let listId = statusName ? listIdMap.get(statusName) : undefined;
|
|
|
|
// Fallback to first list
|
|
if (!listId && createdLists.length > 0) {
|
|
listId = createdLists[0]?.id;
|
|
}
|
|
|
|
if (listId) {
|
|
itemsToInsert.push({
|
|
item,
|
|
listId,
|
|
title,
|
|
description,
|
|
});
|
|
}
|
|
}
|
|
|
|
const cardsInput = itemsToInsert.map((data, index) => ({
|
|
publicId: generateUID(),
|
|
title: data.title,
|
|
description: data.description,
|
|
createdBy: userId,
|
|
listId: data.listId,
|
|
workspaceId: workspace.id,
|
|
index: index,
|
|
importId: newImportId,
|
|
}));
|
|
|
|
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
|
|
|
|
// Create Activities
|
|
const activities = createdCards.map((card) => ({
|
|
type: "card.created" as const,
|
|
cardId: card.id,
|
|
createdBy: userId,
|
|
}));
|
|
|
|
if (activities.length > 0) {
|
|
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
|
}
|
|
|
|
// Link Labels
|
|
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
|
|
createdCards.forEach((card, index) => {
|
|
const originalItem = itemsToInsert[index]?.item;
|
|
const areaName = originalItem?.areaValue?.name;
|
|
|
|
if (areaName) {
|
|
const labelId = labelMap.get(areaName);
|
|
if (labelId) {
|
|
cardLabelRelations.push({
|
|
cardId: card.id,
|
|
labelId: labelId,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
if (cardLabelRelations.length > 0) {
|
|
await cardRepo.bulkCreateCardLabelRelationships(
|
|
ctx.db,
|
|
cardLabelRelations,
|
|
);
|
|
}
|
|
|
|
projectsImported++;
|
|
}
|
|
|
|
if (projectsImported > 0 && newImportId) {
|
|
await importRepo.update(
|
|
ctx.db,
|
|
{ status: "success" },
|
|
{ importId: newImportId },
|
|
);
|
|
}
|
|
|
|
return { projectsImported };
|
|
}),
|
|
}),
|
|
});
|