Files
kan/packages/api/src/routers/import.ts
Henry 7f5a1ab513 feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles

* chore: regen migration

* feat: add publicId to workspace roles

* feat: setup default permissions

* feat: add repo funcs

* feat: setup basic router interactions

* feat: add card permissions

* feat: assert permissions for lists

* feat: assert board permissions

* feat: assert permission for remaining routes

* feat: add permissions page to settings

* feat: enable updating member roles

* feat: order members by role and createdAt

* feat: allow editing individual permissions

* feat: reset role defaults

* feat: clear all permission overrides

* feat: allow users to delete entities they have created

* feat: set roleId when inviting new members

* feat: disable UI elements if user does not have permissions

* feat: allow admins to assign the admin role to other users

* feat: allow delete:list as default

* refactor: centre permissions modal

* chore: translations
2026-02-01 21:17:18 +00:00

468 lines
14 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 { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
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 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 };
}),
}),
});