feat: monorepo

This commit is contained in:
Henry
2024-12-12 14:34:10 +00:00
parent b8eed7a90c
commit 0c8d17dce5
370 changed files with 10280 additions and 39805 deletions

View 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
View 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
View 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 };

View 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
View 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;

View 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 };
}),
});

View 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 };
}),
});

View 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 };
}),
});

View 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 };
}),
}),
});

View 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 };
}),
});

View 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;
}),
});

View 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 };
}),
});

View 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
View 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",
},
});

View 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"];

View File

@@ -0,0 +1,11 @@
{
"extends": "@kan/tsconfig/internal-package.json",
"include": ["packages/**/*", "src"],
"exclude": ["node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
}
}