feat: implement Trello integration with OAuth and user fields (#48)

* feat: implement Trello integration with OAuth and user fields

* feat: migrate Trello integration to new integrations table

* refactor: migrate Trello integration to use new integration system

* refactor: migrate Trello integration to use new integrations table

* fix: add loading check for Trello integration and update VSCode settings

* feat: enhance import boards UI with dynamic integration providers and local storage config

* feat: add select/unselect all buttons and loading state to board import form

* feat: add Trello import env vars on README

* docs: update Trello import guide to use OAuth flow instead of API keys

* feat: add integrations table for OAuth token management

* feat: connect trello from import modal

* refactor: consolidate Trello integration endpoints into unified integration router

* fix: trello import again

* refactor: generalize integration authorization flow and improve error handling

* refactor: update Trello API endpoints and tags for better organization

* feat: add Integrations tag to OpenAPI specification

* refactor: update Trello auth endpoint to use generic integration provider param

* refactor: move Trello integration logic from trello.ts to import.ts router

* refactor: consolidate Trello integration endpoints and fix API URL

* chore: remove debug logs

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
LovelessCodes
2025-06-10 20:24:18 +02:00
committed by GitHub
parent dd6cdfc208
commit 75e89f118f
22 changed files with 2830 additions and 154 deletions

View File

@@ -9,5 +9,5 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
version: "1.0.0",
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
docsUrl: "docs.kan.bn",
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
});

View File

@@ -2,6 +2,7 @@ import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
import { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
import: importRouter,
user: userRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -5,6 +5,7 @@ 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 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";
@@ -13,10 +14,9 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { apiKeys, urls } from "./integration";
const TRELLO_API_URL = "https://api.trello.com/1";
interface TrelloBoard {
export interface TrelloBoard {
id: string;
name: string;
labels: TrelloLabel[];
@@ -42,10 +42,6 @@ interface TrelloCard {
labels: TrelloLabel[];
}
interface MemberData {
idBoards: string[];
}
export const importRouter = createTRPCRouter({
trello: createTRPCRouter({
getBoards: protectedProcedure
@@ -53,46 +49,51 @@ export const importRouter = createTRPCRouter({
openapi: {
summary: "Get boards from Trello",
method: "GET",
path: "/imports/trello/boards",
path: "/integrations/trello/boards",
description: "Retrieves all boards from Trello",
tags: ["Imports"],
tags: ["Integrations"],
protect: true,
},
})
.input(
z.object({
apiKey: z.string().length(32),
token: z.string().length(76),
}),
)
.output(z.array(z.object({ id: z.string(), name: z.string() })))
.query(async ({ input }) => {
const fetchMemberRes = await fetch(
`${TRELLO_API_URL}/tokens/${input.token}/member?key=${input.apiKey}`,
.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 member = (await fetchMemberRes.json()) as MemberData;
const token = integration?.accessToken;
const boardIds = member.idBoards;
if (!token)
throw new TRPCError({
message: "Trello token not found",
code: "UNAUTHORIZED",
});
const fetchBoard = async (boardId: string) => {
const response = await fetch(
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
);
const data = (await response.json()) as TrelloBoard;
const response = await fetch(
`${urls.trello}/members/me/boards?key=${apiKey}&token=${token}`,
);
return data;
};
const data = (await response.json()) as TrelloBoard[];
const boards = [];
for (const boardId of boardIds) {
boards.push(Promise.resolve(fetchBoard(boardId)));
}
const boardDataArray = await Promise.all(boards);
return boardDataArray.map((board) => ({
return data.map((board) => ({
id: board.id,
name: board.name,
}));
@@ -102,7 +103,7 @@ export const importRouter = createTRPCRouter({
openapi: {
summary: "Import boards from Trello",
method: "POST",
path: "/imports/trello/import",
path: "/imports/trello/boards",
description: "Imports boards from Trello",
tags: ["Imports"],
protect: true,
@@ -111,8 +112,6 @@ export const importRouter = createTRPCRouter({
.input(
z.object({
boardIds: z.array(z.string()),
apiKey: z.string().length(32),
token: z.string().length(76),
workspacePublicId: z.string().min(12),
}),
)
@@ -120,12 +119,32 @@ export const importRouter = createTRPCRouter({
.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,
@@ -150,7 +169,7 @@ export const importRouter = createTRPCRouter({
for (const boardId of input.boardIds) {
const response = await fetch(
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}&lists=open&cards=open&labels=all`,
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`,
);
const data = (await response.json()) as TrelloBoard;

View File

@@ -0,0 +1,128 @@
import { TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import { z } from "zod";
import * as integrationsRepo from "@kan/db/repository/integration.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const urls = {
trello: "https://api.trello.com/1",
};
export const apiKeys = {
trello: process.env.TRELLO_APP_API_KEY,
};
export const integrationRouter = createTRPCRouter({
providers: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integrations = await integrationsRepo.getProvidersForUser(
ctx.db,
user.id,
);
return integrations;
}),
disconnect: protectedProcedure
.meta({
openapi: {
summary: "Disconnect integration",
method: "POST",
path: "/integration/disconnect",
description: "Disconnects an integration",
tags: ["Integration"],
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello"]) }))
.output(z.object({}))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
if (!user)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const integration = await integrationsRepo.getProviderForUser(
ctx.db,
user.id,
input.provider,
);
if (!integration)
throw new TRPCError({
message: "Integration not found",
code: "NOT_FOUND",
});
await integrationsRepo.deleteProviderForUser(
ctx.db,
user.id,
input.provider,
);
return {};
}),
getAuthorizationUrl: protectedProcedure
.meta({
openapi: {
summary: "Get authorization URL for an integration",
method: "GET",
path: "/integration/authorize",
description: "Retrieves the authorization URL for an integration",
tags: ["Integration"],
protect: true,
},
})
.input(z.object({ provider: z.enum(["trello"]) }))
.output(z.object({ url: z.string() }))
.query(async ({ ctx, input }) => {
const apiKey = apiKeys[input.provider];
if (!apiKey)
throw new TRPCError({
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} API key not set in environment variables`,
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,
input.provider,
);
if (integration)
throw new TRPCError({
message: `${input.provider.at(0)?.toUpperCase() + input.provider.slice(1)} integration already exists`,
code: "BAD_REQUEST",
});
if (input.provider === "trello") {
const url = `${urls[input.provider]}/authorize?key=${apiKey}&expiration=never&response_type=token&scope=read&return_url=${env("NEXT_PUBLIC_BASE_URL")}/settings/trello/authorize&callback_method=fragment`;
return { url };
}
throw new TRPCError({
message: "Invalid provider",
code: "BAD_REQUEST",
});
}),
});

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "integration" (
"provider" varchar(255) NOT NULL,
"userId" uuid NOT NULL,
"accessToken" varchar(255) NOT NULL,
"refreshToken" varchar(255),
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
);
--> statement-breakpoint
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1749405288761,
"tag": "20250608175448_AddOnDeleteActionToUserDeletion",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1749377372062,
"tag": "20250608100932_AddIntegrationsTable",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,59 @@
import { and, eq, gte } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { integrations } from "@kan/db/schema";
export const isProviderAvailableForUser = async (
db: dbClient,
userId: string,
provider: string,
) => {
const integration = await db.query.integrations.findFirst({
where: and(
eq(integrations.userId, userId),
eq(integrations.provider, provider),
gte(integrations.expiresAt, new Date()),
),
});
return !!integration;
};
export const getProviderForUser = async (
db: dbClient,
userId: string,
provider: string,
) => {
const integration = await db.query.integrations.findFirst({
where: and(
eq(integrations.userId, userId),
eq(integrations.provider, provider),
gte(integrations.expiresAt, new Date()),
),
});
return integration;
};
export const getProvidersForUser = async (db: dbClient, userId: string) => {
const integration = await db.query.integrations.findMany({
where: and(
eq(integrations.userId, userId),
gte(integrations.expiresAt, new Date()),
),
});
return integration;
};
export const deleteProviderForUser = async (
db: dbClient,
userId: string,
provider: string,
) => {
await db
.delete(integrations)
.where(
and(eq(integrations.userId, userId), eq(integrations.provider, provider)),
);
};

View File

@@ -7,4 +7,5 @@ export * from "./imports";
export * from "./labels";
export * from "./lists";
export * from "./users";
export * from "./integrations";
export * from "./workspaces";

View File

@@ -0,0 +1,40 @@
import { relations } from "drizzle-orm";
import {
pgTable,
primaryKey,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const integrations = pgTable(
"integration",
{
provider: varchar("provider", { length: 255 }).notNull(),
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: varchar("accessToken", { length: 255 }).notNull(),
refreshToken: varchar("refreshToken", { length: 255 }),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt")
.$defaultFn(() => new Date())
.notNull(),
updatedAt: timestamp("updatedAt").$onUpdateFn(() => new Date()),
},
(table) => [
primaryKey({
name: "integration_pkey",
columns: [table.userId, table.provider],
}),
],
).enableRLS();
export const integrationsRelations = relations(integrations, ({ one }) => ({
user: one(users, {
fields: [integrations.userId],
references: [users.id],
}),
}));

View File

@@ -13,6 +13,7 @@ import { cards } from "./cards";
import { imports } from "./imports";
import { lists } from "./lists";
import { workspaceMembers, workspaces } from "./workspaces";
import { integrations } from "./integrations";
export const users = pgTable("user", {
id: uuid("id")
@@ -55,6 +56,7 @@ export const usersRelations = relations(users, ({ many }) => ({
relationName: "workspaceCreatedByUser",
}),
apiKeys: many(apikey),
integrations: many(integrations),
}));
export const usersToWorkspacesRelations = relations(