feat: monorepo
This commit is contained in:
9
packages/api/eslint.config.js
Normal file
9
packages/api/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@kan/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**"],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
56
packages/api/package.json
Normal file
56
packages/api/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@kan/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./root": {
|
||||
"types": "./dist/root.d.ts",
|
||||
"default": "./src/root.ts"
|
||||
},
|
||||
"./trpc": {
|
||||
"types": "./dist/trpc.d.ts",
|
||||
"default": "./src/trpc.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./src/types/router.types.ts"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kan/db": "workspace:*",
|
||||
"@kan/email": "workspace:^",
|
||||
"@kan/supabase": "workspace:^",
|
||||
"@kan/utils": "workspace:^",
|
||||
"@trpc/server": "catalog:",
|
||||
"superjson": "2.2.1",
|
||||
"trpc-to-openapi": "^2.1.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kan/eslint-config": "workspace:*",
|
||||
"@kan/prettier-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
13
packages/api/src/index.ts
Normal file
13
packages/api/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "./root";
|
||||
import { appRouter } from "./root";
|
||||
import { createCallerFactory, createTRPCContext } from "./trpc";
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
|
||||
type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
export { createTRPCContext, appRouter, createCaller };
|
||||
export type { AppRouter, RouterInputs, RouterOutputs };
|
||||
12
packages/api/src/openapi.ts
Normal file
12
packages/api/src/openapi.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { generateOpenApiDocument } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter } from "./root";
|
||||
|
||||
export const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||
title: "Kan API",
|
||||
description: "OpenAPI compliant REST API",
|
||||
version: "1.0.0",
|
||||
baseUrl: `${process.env.WEBSITE_URL}/api/v1`,
|
||||
docsUrl: "docs.kan.bn",
|
||||
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports"],
|
||||
});
|
||||
22
packages/api/src/root.ts
Normal file
22
packages/api/src/root.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { authRouter } from "./routers/auth";
|
||||
import { boardRouter } from "./routers/board";
|
||||
import { cardRouter } from "./routers/card";
|
||||
import { importRouter } from "./routers/import";
|
||||
import { labelRouter } from "./routers/label";
|
||||
import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
auth: authRouter,
|
||||
board: boardRouter,
|
||||
card: cardRouter,
|
||||
label: labelRouter,
|
||||
list: listRouter,
|
||||
member: memberRouter,
|
||||
import: importRouter,
|
||||
workspace: workspaceRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
116
packages/api/src/routers/auth.ts
Normal file
116
packages/api/src/routers/auth.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
getUser: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/users/me",
|
||||
summary: "Get user",
|
||||
description:
|
||||
"Retrieves the currently authenticated user's profile information",
|
||||
tags: ["Users"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const result = await userRepo.getById(ctx.db, userId);
|
||||
|
||||
if (!result?.name) {
|
||||
throw new TRPCError({
|
||||
message: `User not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
loginWithEmail: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/auth/login/email",
|
||||
summary: "Login with email",
|
||||
description: "Sends a login URL to the provided email address",
|
||||
tags: ["Auth"],
|
||||
},
|
||||
})
|
||||
.input(z.object({ email: z.string() }))
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { data } = await ctx.db.auth.signInWithOtp({
|
||||
email: input.email,
|
||||
options: {
|
||||
emailRedirectTo: `${process.env.WEBSITE_URL}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data)
|
||||
throw new TRPCError({
|
||||
message: `Failed to login with email`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
loginWithOAuth: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/auth/login/oauth",
|
||||
summary: "Login with OAuth",
|
||||
description:
|
||||
"Initiates the login process for a user with the given OAuth provider",
|
||||
tags: ["Auth"],
|
||||
},
|
||||
})
|
||||
.input(z.object({ provider: z.string() }))
|
||||
.output(z.object({ url: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.provider !== "google")
|
||||
throw new TRPCError({
|
||||
message: `Unsupported OAuth provider: ${input.provider}`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
const { data } = await ctx.db.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
queryParams: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
},
|
||||
redirectTo: `${process.env.WEBSITE_URL}/api/auth/confirm`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data.url)
|
||||
throw new TRPCError({
|
||||
message: `Failed to login with OAuth`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return { url: data.url };
|
||||
}),
|
||||
});
|
||||
244
packages/api/src/routers/board.ts
Normal file
244
packages/api/src/routers/board.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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 activityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/boards",
|
||||
summary: "Get all boards",
|
||||
description: "Retrieves all boards for a given workspace",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
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",
|
||||
});
|
||||
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id);
|
||||
|
||||
return result;
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/boards/{boardPublicId}",
|
||||
summary: "Get board by public ID",
|
||||
description: "Retrieves a board by its public ID",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
members: z.array(z.string().min(12)).optional(),
|
||||
labels: z.array(z.string().min(12)).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
{
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/boards",
|
||||
summary: "Create board",
|
||||
description: "Creates a new board for a given workspace",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
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",
|
||||
});
|
||||
|
||||
const result = await boardRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
createdBy: userId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create board`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PUT",
|
||||
path: "/boards/{boardPublicId}",
|
||||
summary: "Update board",
|
||||
description: "Updates a board by its public ID",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
name: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await boardRepo.update(ctx.db, {
|
||||
name: input.name,
|
||||
boardPublicId: input.boardPublicId,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update board`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "DELETE",
|
||||
path: "/boards/{boardPublicId}",
|
||||
summary: "Delete board",
|
||||
description: "Deletes a board by its public ID",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const board = await boardRepo.getWithListIdsByPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
);
|
||||
|
||||
if (!board)
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const listIds = board.lists.map((list) => list.id);
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
|
||||
await boardRepo.softDelete(ctx.db, {
|
||||
boardId: board.id,
|
||||
deletedAt,
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (listIds.length) {
|
||||
const deletedLists = await listRepo.softDeleteAllByBoardId(ctx.db, {
|
||||
boardId: board.id,
|
||||
deletedAt,
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!Array.isArray(deletedLists)) {
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete lists`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
const deletedCards = await cardRepo.softDeleteAllByListIds(ctx.db, {
|
||||
listIds,
|
||||
deletedAt,
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!Array.isArray(deletedCards)) {
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete cards`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
const activities = deletedCards.map((card) => ({
|
||||
type: "card.archived" as const,
|
||||
createdBy: userId,
|
||||
cardId: card.id,
|
||||
}));
|
||||
|
||||
await activityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
762
packages/api/src/routers/card.ts
Normal file
762
packages/api/src/routers/card.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardCommentRepo from "@kan/db/repository/cardComment.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 { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a card",
|
||||
method: "POST",
|
||||
path: "/cards",
|
||||
description: "Creates a new card for a given list",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().max(10000),
|
||||
listPublicId: z.string().min(12),
|
||||
labelPublicIds: z.array(z.string().min(12)),
|
||||
memberPublicIds: z.array(z.string().min(12)),
|
||||
position: z.enum(["start", "end"]),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const list = await listRepo.getWithCardsByPublicId(
|
||||
ctx.db,
|
||||
input.listPublicId,
|
||||
);
|
||||
|
||||
if (!list?.id)
|
||||
throw new TRPCError({
|
||||
message: `List with public ID ${input.listPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const lastCard = list.cards.length && list.cards[0];
|
||||
|
||||
let index = 0;
|
||||
|
||||
if (list.cards.length) {
|
||||
if (input.position === "end" && lastCard) index = lastCard.index + 1;
|
||||
|
||||
if (input.position === "start") {
|
||||
await cardRepo.pushIndex(ctx.db, {
|
||||
listId: list.id,
|
||||
cardIndex: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newCard = await cardRepo.create(ctx.db, {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
createdBy: userId,
|
||||
listId: list.id,
|
||||
index,
|
||||
});
|
||||
|
||||
const newCardId = newCard?.id;
|
||||
|
||||
if (!newCardId)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.created",
|
||||
cardId: newCard.id,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (newCardId && input.labelPublicIds.length) {
|
||||
const labels = await labelRepo.getAllByPublicIds(
|
||||
ctx.db,
|
||||
input.labelPublicIds,
|
||||
);
|
||||
|
||||
if (!labels?.length)
|
||||
throw new TRPCError({
|
||||
message: `Labels with public IDs (${input.labelPublicIds.join(", ")}) not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const labelsInsert = labels.map((label) => ({
|
||||
cardId: newCardId,
|
||||
labelId: label.id,
|
||||
}));
|
||||
|
||||
const cardLabels = await cardRepo.bulkCreateCardLabelRelationships(
|
||||
ctx.db,
|
||||
labelsInsert,
|
||||
);
|
||||
|
||||
if (!cardLabels?.length)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create card label relationships`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const cardActivitesInsert = cardLabels.map((cardLabel) => ({
|
||||
type: "card.updated.label.added" as const,
|
||||
cardId: cardLabel.cardId,
|
||||
labelId: cardLabel.labelId,
|
||||
createdBy: userId,
|
||||
}));
|
||||
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
|
||||
if (newCardId && input.memberPublicIds.length) {
|
||||
const members = await workspaceRepo.getAllMembersByPublicIds(
|
||||
ctx.db,
|
||||
input.memberPublicIds,
|
||||
);
|
||||
|
||||
if (!members?.length)
|
||||
throw new TRPCError({
|
||||
message: `Members with public IDs (${input.memberPublicIds.join(", ")}) not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const membersInsert = members.map((member) => ({
|
||||
cardId: newCardId,
|
||||
workspaceMemberId: member.id,
|
||||
}));
|
||||
|
||||
const cardMembers =
|
||||
await cardRepo.bulkCreateCardWorkspaceMemberRelationships(
|
||||
ctx.db,
|
||||
membersInsert,
|
||||
);
|
||||
|
||||
if (!cardMembers?.length)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create card member relationships`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const cardActivitesInsert = cardMembers.map((cardMember) => ({
|
||||
type: "card.updated.member.added" as const,
|
||||
cardId: cardMember.cardId,
|
||||
workspaceMemberId: cardMember.workspaceMemberId,
|
||||
createdBy: userId,
|
||||
}));
|
||||
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
|
||||
return newCard;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add a comment to a card",
|
||||
method: "POST",
|
||||
path: "/cards/{cardPublicId}/comments",
|
||||
description: "Adds a comment to a card",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
|
||||
if (!card)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const newComment = await cardCommentRepo.create(ctx.db, {
|
||||
comment: input.comment,
|
||||
createdBy: userId,
|
||||
cardId: card.id,
|
||||
});
|
||||
|
||||
if (!newComment?.id)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create comment`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.comment.added" as const,
|
||||
cardId: card.id,
|
||||
commentId: newComment.id,
|
||||
toComment: newComment.comment,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return newComment;
|
||||
}),
|
||||
updateComment: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a comment",
|
||||
method: "PUT",
|
||||
path: "/cards/{cardPublicId}/comments/{commentPublicId}",
|
||||
description: "Updates a comment",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
commentPublicId: z.string().min(12),
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
const existingComment = await cardCommentRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.commentPublicId,
|
||||
);
|
||||
|
||||
if (!card)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (!existingComment)
|
||||
throw new TRPCError({
|
||||
message: `Comment with public ID ${input.commentPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (existingComment.createdBy !== userId)
|
||||
throw new TRPCError({
|
||||
message: `You do not have permission to update this comment`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
const updatedComment = await cardCommentRepo.update(ctx.db, {
|
||||
id: existingComment.id,
|
||||
comment: input.comment,
|
||||
});
|
||||
|
||||
if (!updatedComment?.id)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update comment`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.comment.updated" as const,
|
||||
cardId: card.id,
|
||||
commentId: updatedComment.id,
|
||||
fromComment: existingComment.comment,
|
||||
toComment: updatedComment.comment,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
}),
|
||||
addOrRemoveLabel: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add or remove a label from a card",
|
||||
method: "PUT",
|
||||
path: "/cards/{cardPublicId}/labels/{labelPublicId}",
|
||||
description: "Adds or removes a label from a card",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
labelPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ newLabel: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||
|
||||
if (!card)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (!label)
|
||||
throw new TRPCError({
|
||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const cardLabelIds = { cardId: card.id, labelId: label.id };
|
||||
|
||||
const existingLabel = await cardRepo.getCardLabelRelationship(
|
||||
ctx.db,
|
||||
cardLabelIds,
|
||||
);
|
||||
|
||||
if (existingLabel) {
|
||||
const deletedCardLabelRelationship =
|
||||
await cardRepo.hardDeleteCardLabelRelationship(ctx.db, cardLabelIds);
|
||||
|
||||
if (!deletedCardLabelRelationship)
|
||||
throw new TRPCError({
|
||||
message: `Failed to remove label from card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.label.removed" as const,
|
||||
cardId: card.id,
|
||||
labelId: label.id,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { newLabel: false };
|
||||
}
|
||||
|
||||
const newCardLabelRelationship =
|
||||
await cardRepo.createCardLabelRelationship(ctx.db, cardLabelIds);
|
||||
|
||||
if (!newCardLabelRelationship)
|
||||
throw new TRPCError({
|
||||
message: `Failed to add label to card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.label.added" as const,
|
||||
cardId: card.id,
|
||||
labelId: label.id,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { newLabel: true };
|
||||
}),
|
||||
addOrRemoveMember: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add or remove a member from a card",
|
||||
method: "PUT",
|
||||
path: "/cards/{cardPublicId}/members/{workspaceMemberPublicId}",
|
||||
description: "Adds or removes a member from a card",
|
||||
tags: ["Cards"],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
workspaceMemberPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ newMember: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
const member = await workspaceRepo.getMemberByPublicId(
|
||||
ctx.db,
|
||||
input.workspaceMemberPublicId,
|
||||
);
|
||||
|
||||
if (!card)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (!member)
|
||||
throw new TRPCError({
|
||||
message: `Member with public ID ${input.workspaceMemberPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const cardMemberIds = { cardId: card.id, memberId: member.id };
|
||||
|
||||
const existingMember = await cardRepo.getCardMemberRelationship(
|
||||
ctx.db,
|
||||
cardMemberIds,
|
||||
);
|
||||
|
||||
if (existingMember) {
|
||||
const deletedCardMemberRelationship =
|
||||
await cardRepo.hardDeleteCardMemberRelationship(
|
||||
ctx.db,
|
||||
cardMemberIds,
|
||||
);
|
||||
|
||||
if (!deletedCardMemberRelationship.success)
|
||||
throw new TRPCError({
|
||||
message: `Failed to remove member from card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.member.removed" as const,
|
||||
cardId: card.id,
|
||||
workspaceMemberId: member.id,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { newMember: false };
|
||||
}
|
||||
|
||||
const newCardMemberRelationship =
|
||||
await cardRepo.createCardMemberRelationship(ctx.db, cardMemberIds);
|
||||
|
||||
if (!newCardMemberRelationship.success)
|
||||
throw new TRPCError({
|
||||
message: `Failed to add member to card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.member.added" as const,
|
||||
cardId: card.id,
|
||||
workspaceMemberId: member.id,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { newMember: true };
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get a card by public ID",
|
||||
method: "GET",
|
||||
path: "/cards/{cardPublicId}",
|
||||
description: "Retrieves a card by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ cardPublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a card",
|
||||
method: "PUT",
|
||||
path: "/cards/{cardPublicId}",
|
||||
description: "Updates a card by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
title: z.string().min(1),
|
||||
description: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const existingCard = await cardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!existingCard) {
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await cardRepo.update(
|
||||
ctx.db,
|
||||
{ title: input.title, description: input.description },
|
||||
{ cardPublicId: input.cardPublicId },
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const activities = [];
|
||||
|
||||
if (existingCard.title !== input.title) {
|
||||
activities.push({
|
||||
type: "card.updated.title" as const,
|
||||
cardId: result.id,
|
||||
createdBy: userId,
|
||||
fromTitle: existingCard.title,
|
||||
toTitle: input.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (existingCard.description !== input.description) {
|
||||
activities.push({
|
||||
type: "card.updated.description" as const,
|
||||
cardId: result.id,
|
||||
createdBy: userId,
|
||||
fromDescription: existingCard.description ?? undefined,
|
||||
toDescription: input.description,
|
||||
});
|
||||
}
|
||||
|
||||
if (activities.length > 0) {
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a card",
|
||||
method: "DELETE",
|
||||
path: "/cards/{cardPublicId}",
|
||||
description: "Deletes a card by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getCardWithListByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!card?.list?.id)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
|
||||
const deletedCard = await cardRepo.softDelete(ctx.db, {
|
||||
cardId: card.id,
|
||||
deletedAt,
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!deletedCard)
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardRepo.shiftIndex(ctx.db, {
|
||||
listId: card.list.id,
|
||||
cardIndex: card.index,
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.archived",
|
||||
cardId: card.id,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
reorder: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Reorder a card",
|
||||
method: "PUT",
|
||||
path: "/cards/{cardPublicId}/reorder",
|
||||
description: "Reorders the position of a card in a given list",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
newListPublicId: z.string().min(12),
|
||||
newIndex: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getCardWithListByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!card?.list)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const currentList = card.list;
|
||||
const currentIndex = card.index;
|
||||
|
||||
let newIndex = input.newIndex;
|
||||
|
||||
const newList = await listRepo.getWithCardsByPublicId(
|
||||
ctx.db,
|
||||
input.newListPublicId,
|
||||
);
|
||||
|
||||
if (!newList)
|
||||
throw new TRPCError({
|
||||
message: `List with public ID ${input.newListPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (newIndex === undefined) {
|
||||
const lastCardIndex = newList.cards.length
|
||||
? newList.cards[0]?.index
|
||||
: undefined;
|
||||
|
||||
newIndex = lastCardIndex !== undefined ? lastCardIndex + 1 : 0;
|
||||
}
|
||||
|
||||
const { success } = await cardRepo.reorder(ctx.db, {
|
||||
currentListId: currentList.id,
|
||||
newListId: newList.id,
|
||||
currentIndex,
|
||||
newIndex,
|
||||
cardId: card.id,
|
||||
});
|
||||
|
||||
if (!success)
|
||||
throw new TRPCError({
|
||||
message: `Failed to reorder card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const activities = [];
|
||||
|
||||
if (currentIndex !== newIndex) {
|
||||
activities.push({
|
||||
type: "card.updated.index" as const,
|
||||
cardId: card.id,
|
||||
createdBy: userId,
|
||||
fromIndex: currentIndex,
|
||||
toIndex: newIndex,
|
||||
});
|
||||
}
|
||||
|
||||
if (currentList.id !== newList.id) {
|
||||
activities.push({
|
||||
type: "card.updated.list" as const,
|
||||
cardId: card.id,
|
||||
createdBy: userId,
|
||||
fromListId: currentList.id,
|
||||
toListId: newList.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (activities.length > 0) {
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
return { success };
|
||||
}),
|
||||
});
|
||||
239
packages/api/src/routers/import.ts
Normal file
239
packages/api/src/routers/import.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
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 importRepo from "@kan/db/repository/import.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
const TRELLO_API_URL = "https://api.trello.com/1";
|
||||
|
||||
interface TrelloBoard {
|
||||
id: string;
|
||||
name: string;
|
||||
lists: TrelloList[];
|
||||
cards: TrelloCard[];
|
||||
}
|
||||
|
||||
interface TrelloList {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TrelloCard {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
idList: string;
|
||||
}
|
||||
|
||||
interface MemberData {
|
||||
idBoards: string[];
|
||||
}
|
||||
|
||||
export const importRouter = createTRPCRouter({
|
||||
trello: createTRPCRouter({
|
||||
getBoards: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get boards from Trello",
|
||||
method: "GET",
|
||||
path: "/imports/trello/boards",
|
||||
description: "Retrieves all boards from Trello",
|
||||
tags: ["Imports"],
|
||||
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}`,
|
||||
);
|
||||
|
||||
const member = (await fetchMemberRes.json()) as MemberData;
|
||||
|
||||
const boardIds = member.idBoards;
|
||||
|
||||
const fetchBoard = async (boardId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${TRELLO_API_URL}/boards/${boardId}?key=${input.apiKey}&token=${input.token}`,
|
||||
);
|
||||
const data = (await response.json()) as TrelloBoard;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const boards = [];
|
||||
|
||||
for (const boardId of boardIds) {
|
||||
boards.push(Promise.resolve(fetchBoard(boardId)));
|
||||
}
|
||||
|
||||
const boardDataArray = await Promise.all(boards);
|
||||
|
||||
return boardDataArray.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
}));
|
||||
}),
|
||||
importBoards: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Import boards from Trello",
|
||||
method: "POST",
|
||||
path: "/imports/trello/import",
|
||||
description: "Imports boards from Trello",
|
||||
tags: ["Imports"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardIds: z.array(z.string()),
|
||||
apiKey: z.string().length(32),
|
||||
token: z.string().length(76),
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ boardsCreated: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const newImport = await importRepo.create(ctx.db, {
|
||||
source: "trello",
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const newImportId = newImport?.id;
|
||||
|
||||
let boardsCreated = 0;
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
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`,
|
||||
);
|
||||
const data = (await response.json()) as TrelloBoard;
|
||||
|
||||
const formattedData = {
|
||||
name: data.name,
|
||||
lists: data.lists.map((list) => ({
|
||||
name: list.name,
|
||||
cards: data.cards
|
||||
.filter((card) => card.idList === list.id)
|
||||
.map((_card) => ({
|
||||
name: _card.name,
|
||||
description: _card.desc,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const newBoard = await boardRepo.create(ctx.db, {
|
||||
name: formattedData.name,
|
||||
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 listIndex = 0;
|
||||
|
||||
for (const list of formattedData.lists) {
|
||||
const newList = await listRepo.create(ctx.db, {
|
||||
name: list.name,
|
||||
createdBy: userId,
|
||||
boardId: newBoardId,
|
||||
index: listIndex,
|
||||
importId: newImportId,
|
||||
});
|
||||
|
||||
const newListId = newList?.id;
|
||||
|
||||
if (list.cards.length && newListId) {
|
||||
const cardsInsert = list.cards.map((card, index) => ({
|
||||
publicId: generateUID(),
|
||||
title: card.name,
|
||||
description: card.description,
|
||||
createdBy: userId,
|
||||
listId: newListId,
|
||||
index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
|
||||
const createdCards = await cardRepo.bulkCreate(
|
||||
ctx.db,
|
||||
cardsInsert,
|
||||
);
|
||||
|
||||
if (!createdCards?.length)
|
||||
throw new TRPCError({
|
||||
message: "Failed to create new cards",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const activities = createdCards.map((card) => ({
|
||||
type: "card.created" as const,
|
||||
cardId: card.id,
|
||||
createdBy: userId,
|
||||
}));
|
||||
|
||||
if (createdCards.length > 0) {
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
}
|
||||
|
||||
listIndex++;
|
||||
}
|
||||
boardsCreated++;
|
||||
}
|
||||
|
||||
if (boardsCreated > 0 && newImportId) {
|
||||
await importRepo.update(
|
||||
ctx.db,
|
||||
{ status: "success" },
|
||||
{ importId: newImport.id },
|
||||
);
|
||||
}
|
||||
|
||||
return { boardsCreated };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
140
packages/api/src/routers/label.ts
Normal file
140
packages/api/src/routers/label.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const labelRouter = createTRPCRouter({
|
||||
byPublicId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get a label by public ID",
|
||||
method: "GET",
|
||||
path: "/labels/{labelPublicId}",
|
||||
description: "Retrieves a label by its public ID",
|
||||
tags: ["Labels"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ labelPublicId: z.string().min(12) }))
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.getByPublicId>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||
|
||||
if (!label)
|
||||
throw new TRPCError({
|
||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
return label;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a label",
|
||||
method: "POST",
|
||||
path: "/labels",
|
||||
description: "Creates a new label",
|
||||
tags: ["Labels"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(36),
|
||||
cardPublicId: z.string().min(12),
|
||||
colourCode: z.string().length(7),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getCardWithListByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!card?.list)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = await labelRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
colourCode: input.colourCode,
|
||||
createdBy: userId,
|
||||
boardId: card.list.boardId,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create label`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a label",
|
||||
method: "PUT",
|
||||
path: "/labels/{labelPublicId}",
|
||||
description: "Updates a label by its public ID",
|
||||
tags: ["Labels"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
labelPublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(36),
|
||||
colourCode: z.string().length(7),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await labelRepo.update(ctx.db, input);
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a label",
|
||||
method: "DELETE",
|
||||
path: "/labels/{labelPublicId}",
|
||||
description: "Deletes a label by its public ID",
|
||||
tags: ["Labels"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ labelPublicId: z.string().min(12) }))
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId);
|
||||
|
||||
if (!label)
|
||||
throw new TRPCError({
|
||||
message: `Label with public ID ${input.labelPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await cardRepo.hardDeleteAllCardLabelRelationships(ctx.db, label.id);
|
||||
|
||||
await labelRepo.hardDelete(ctx.db, label.id);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
219
packages/api/src/routers/list.ts
Normal file
219
packages/api/src/routers/list.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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 activityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const listRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a list",
|
||||
method: "POST",
|
||||
path: "/lists",
|
||||
description: "Creates a new list for a given board",
|
||||
tags: ["Lists"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const board = await boardRepo.getWithLatestListIndexByPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
);
|
||||
|
||||
if (!board)
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const latestListIndex = board.lists[0]?.index;
|
||||
|
||||
const result = await listRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
createdBy: userId,
|
||||
boardId: board.id,
|
||||
index:
|
||||
(latestListIndex ?? latestListIndex === 0) ? latestListIndex + 1 : 0,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create list`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
reorder: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Reorder a list",
|
||||
method: "POST",
|
||||
path: "/lists/{listPublicId}/reorder",
|
||||
description: "Reorders the position of a list",
|
||||
tags: ["Lists"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
listPublicId: z.string().min(12),
|
||||
currentIndex: z.number(),
|
||||
newIndex: z.number(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof listRepo.reorder>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const list = await listRepo.getByPublicId(ctx.db, input.listPublicId);
|
||||
|
||||
if (!list)
|
||||
throw new TRPCError({
|
||||
message: `List with public ID ${input.listPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = await listRepo.reorder(ctx.db, {
|
||||
boardPublicId: list.boardId,
|
||||
listPublicId: list.id,
|
||||
currentIndex: input.currentIndex,
|
||||
newIndex: input.newIndex,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to reorder list`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a list",
|
||||
method: "DELETE",
|
||||
path: "/lists/{listPublicId}",
|
||||
description: "Deletes a list by its public ID",
|
||||
tags: ["Lists"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
listPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const list = await listRepo.getByPublicId(ctx.db, input.listPublicId);
|
||||
|
||||
if (!list)
|
||||
throw new TRPCError({
|
||||
message: `List with public ID ${input.listPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
|
||||
const deletedList = await listRepo.softDeleteById(ctx.db, {
|
||||
listId: list.id,
|
||||
deletedAt,
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!deletedList)
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete list`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const deletedCards = await cardRepo.softDeleteAllByListIds(ctx.db, {
|
||||
listIds: [list.id],
|
||||
deletedAt,
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!Array.isArray(deletedCards))
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete cards`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const activities = deletedCards.map((card) => ({
|
||||
type: "card.archived" as const,
|
||||
createdBy: userId,
|
||||
cardId: card.id,
|
||||
}));
|
||||
|
||||
await activityRepo.bulkCreate(ctx.db, activities);
|
||||
|
||||
await listRepo.shiftIndex(ctx.db, {
|
||||
boardId: list.boardId,
|
||||
listIndex: list.index,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a list",
|
||||
method: "PUT",
|
||||
path: "/lists/{listPublicId}",
|
||||
description: "Updates a list by its public ID",
|
||||
tags: ["Lists"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
listPublicId: z.string().min(12),
|
||||
name: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof listRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await listRepo.update(
|
||||
ctx.db,
|
||||
{ name: input.name },
|
||||
{ listPublicId: input.listPublicId },
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update list`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
208
packages/api/src/routers/member.ts
Normal file
208
packages/api/src/routers/member.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
// import { sendEmail } from "@kan/email";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const memberRouter = createTRPCRouter({
|
||||
invite: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Invite a member to a workspace",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/members/invite",
|
||||
description: "Invites a member to a workspace",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicIdWithMembers(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const isInvitedEmailAlreadyMember = workspace.members.some(
|
||||
(member) => member.user?.email === input.email,
|
||||
);
|
||||
|
||||
if (isInvitedEmailAlreadyMember) {
|
||||
throw new TRPCError({
|
||||
message: `User with email ${input.email} is already a member of this workspace`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
let invitedUserId: string | undefined;
|
||||
let hashedToken: string | undefined;
|
||||
let verificationType: string | undefined;
|
||||
|
||||
const existingUser = await userRepo.getByEmail(ctx.adminDb, input.email);
|
||||
|
||||
if (existingUser) {
|
||||
invitedUserId = existingUser.id;
|
||||
|
||||
const magicLink = await ctx.adminDb.auth.admin.generateLink({
|
||||
type: "magiclink",
|
||||
email: input.email,
|
||||
options: {
|
||||
redirectTo: process.env.WEBSITE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
hashedToken = magicLink.data.properties?.hashed_token;
|
||||
verificationType = magicLink.data.properties?.verification_type;
|
||||
} else {
|
||||
const invite = await ctx.adminDb.auth.admin.generateLink({
|
||||
type: "invite",
|
||||
email: input.email,
|
||||
options: {
|
||||
redirectTo: process.env.WEBSITE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
hashedToken = invite.data.properties?.hashed_token;
|
||||
verificationType = invite.data.properties?.verification_type;
|
||||
|
||||
const invitedUserAuthId = invite.data.user?.id;
|
||||
const invitedUserEmail = invite.data.user?.email;
|
||||
|
||||
if (invitedUserAuthId && invitedUserEmail) {
|
||||
const newUser = await userRepo.create(ctx.adminDb, {
|
||||
email: invitedUserEmail,
|
||||
id: invitedUserAuthId,
|
||||
});
|
||||
|
||||
invitedUserId = newUser?.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!invitedUserId)
|
||||
throw new TRPCError({
|
||||
message: `Unable to invite user with email ${input.email}`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
if (!hashedToken || !verificationType)
|
||||
throw new TRPCError({
|
||||
message: `Unable to generate magic link for user with email ${input.email}`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const invite = await memberRepo.create(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
userId: invitedUserId,
|
||||
createdBy: userId,
|
||||
role: "member",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
if (!invite)
|
||||
throw new TRPCError({
|
||||
message: `Unable to invite user with email ${input.email}`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const magicLoginUrl = `${process.env.WEBSITE_URL}/api/auth/confirm?token_hash=${hashedToken}&type=${verificationType}&memberPublicId=${invite.publicId}`;
|
||||
|
||||
// await sendEmail(
|
||||
// input.email,
|
||||
// "Invitation to join workspace",
|
||||
// "JOIN_WORKSPACE",
|
||||
// {
|
||||
// magicLoginUrl,
|
||||
// },
|
||||
// );
|
||||
|
||||
return invite;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a member from a workspace",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}",
|
||||
description: "Deletes a member from a workspace",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
memberPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
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",
|
||||
});
|
||||
|
||||
const member = await memberRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member)
|
||||
throw new TRPCError({
|
||||
message: `Member with public ID ${input.memberPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const deletedMember = await memberRepo.softDelete(ctx.db, {
|
||||
memberId: member.id,
|
||||
deletedAt: new Date().toISOString(),
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!deletedMember)
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete member with public ID ${input.memberPublicId}`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
162
packages/api/src/routers/workspace.ts
Normal file
162
packages/api/src/routers/workspace.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get all workspaces",
|
||||
method: "GET",
|
||||
path: "/workspaces",
|
||||
description: "Retrieves all workspaces for the authenticated user",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const result = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
|
||||
return result;
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get a workspace by public ID",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}",
|
||||
description: "Retrieves a workspace by its public ID",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await workspaceRepo.getByPublicIdWithMembers(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a workspace",
|
||||
method: "POST",
|
||||
path: "/workspaces",
|
||||
description: "Creates a new workspace",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const result = await workspaceRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
slug: input.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!result.publicId)
|
||||
throw new TRPCError({
|
||||
message: `Unable to create workspace`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a workspace",
|
||||
method: "PUT",
|
||||
path: "/workspaces/{workspacePublicId}",
|
||||
description: "Updates a workspace by its public ID",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
name: z.string().min(3).max(24),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await workspaceRepo.update(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
input.name,
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a workspace",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}",
|
||||
description: "Deletes a workspace by its public ID",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Unable to delete workspace`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
108
packages/api/src/trpc.ts
Normal file
108
packages/api/src/trpc.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
import type { OpenApiMeta } from "trpc-to-openapi";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import type { Database } from "@kan/db/types/database.types";
|
||||
import type { SupabaseClient } from "@kan/supabase";
|
||||
import {
|
||||
createNextApiClient,
|
||||
createTRPCAdminClient,
|
||||
createTRPCClient,
|
||||
} from "@kan/supabase";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CreateContextOptions {
|
||||
user: User | null;
|
||||
db: SupabaseClient<Database>;
|
||||
adminDb: SupabaseClient<Database>;
|
||||
}
|
||||
|
||||
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
return {
|
||||
user: opts.user,
|
||||
db: opts.db,
|
||||
adminDb: opts.adminDb,
|
||||
};
|
||||
};
|
||||
|
||||
export const createTRPCContext = async ({
|
||||
req,
|
||||
resHeaders,
|
||||
}: FetchCreateContextFnOptions) => {
|
||||
const db = createTRPCClient(req, resHeaders);
|
||||
const adminDb = createTRPCAdminClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await db.auth.getUser();
|
||||
|
||||
return createInnerTRPCContext({ db, adminDb, user });
|
||||
};
|
||||
|
||||
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||
const db = createNextApiClient(req);
|
||||
const adminDb = createTRPCAdminClient();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const accessToken = authHeader?.startsWith("Bearer ")
|
||||
? authHeader.substring(7)
|
||||
: null;
|
||||
|
||||
if (!accessToken) {
|
||||
return createInnerTRPCContext({ db, adminDb, user: null });
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await db.auth.getUser(accessToken);
|
||||
|
||||
return createInnerTRPCContext({ db, adminDb, user });
|
||||
};
|
||||
|
||||
const t = initTRPC
|
||||
.context<typeof createTRPCContext>()
|
||||
.meta<OpenApiMeta>()
|
||||
.create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
export const publicProcedure = t.procedure.meta({
|
||||
openapi: { method: "GET", path: "/public" },
|
||||
});
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx,
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/protected",
|
||||
},
|
||||
});
|
||||
12
packages/api/src/types/router.types.ts
Normal file
12
packages/api/src/types/router.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RouterInputs, RouterOutputs } from "../index";
|
||||
|
||||
export type GetBoardByIdOutput = RouterOutputs["board"]["byId"];
|
||||
export type GetCardByIdOutput = RouterOutputs["card"]["byId"];
|
||||
export type ReorderListInput = RouterInputs["list"]["reorder"];
|
||||
export type ReorderCardInput = RouterInputs["card"]["reorder"];
|
||||
export type UpdateBoardInput = RouterInputs["board"]["update"];
|
||||
export type NewLabelInput = RouterInputs["label"]["create"];
|
||||
export type NewListInput = RouterInputs["list"]["create"];
|
||||
export type NewCardInput = RouterInputs["card"]["create"];
|
||||
export type NewBoardInput = RouterInputs["board"]["create"];
|
||||
export type InviteMemberInput = RouterInputs["member"]["invite"];
|
||||
11
packages/api/tsconfig.json
Normal file
11
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@kan/tsconfig/internal-package.json",
|
||||
"include": ["packages/**/*", "src"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user