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, 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(); 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(); createdLists.forEach((list, index) => { const originalName = listsInsert[index]?.name; if (originalName) { listIdMap.set(originalName, list.id); } }); // Prepare Cards const itemsToInsert: { item: NonNullable< NonNullable< NonNullable["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, 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 }; }), }), });