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/*"]
}
}
}

View File

@@ -0,0 +1,11 @@
import { type Config } from "drizzle-kit";
export default {
schema: "./src/server/db/schema.ts",
out: "./src/server/db/migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.POSTGRES_URL,
},
// tablesFilter: ["kan_*"],
} satisfies Config;

View File

@@ -0,0 +1,9 @@
import baseConfig from "@kan/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: ["dist/**"],
},
...baseConfig,
];

View File

@@ -0,0 +1,317 @@
DO $$ BEGIN
CREATE TYPE "source" AS ENUM('trello');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "status" AS ENUM('started', 'success', 'failed');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
CREATE TYPE "role" AS ENUM('admin', 'member', 'guest');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "account" (
"userId" uuid NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"providerAccountId" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255),
CONSTRAINT account_provider_providerAccountId PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "board" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"name" varchar(255) NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp DEFAULT now(),
"deletedBy" uuid,
"importId" bigint,
"workspaceId" bigint NOT NULL,
CONSTRAINT "board_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "_card_workspace_members" (
"cardId" bigint NOT NULL,
"workspaceMemberId" bigint NOT NULL,
CONSTRAINT _card_workspace_members_cardId_workspaceMemberId PRIMARY KEY("cardId","workspaceMemberId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "card" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"title" varchar(255) NOT NULL,
"description" text,
"index" integer NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
"listId" bigint NOT NULL,
"importId" bigint,
CONSTRAINT "card_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "_card_labels" (
"cardId" bigint NOT NULL,
"labelId" bigint NOT NULL,
CONSTRAINT _card_labels_cardId_labelId PRIMARY KEY("cardId","labelId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "import" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"source" "source" NOT NULL,
"status" "status" NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "import_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "label" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"name" varchar(255) NOT NULL,
"colourCode" varchar(12),
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"boardId" bigint NOT NULL,
"importId" bigint,
CONSTRAINT "label_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "list" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"name" varchar(255) NOT NULL,
"index" integer NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
"boardId" bigint NOT NULL,
"importId" bigint,
CONSTRAINT "list_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "session" (
"sessionToken" varchar(255) PRIMARY KEY NOT NULL,
"userId" uuid NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "user" (
"id" uuid PRIMARY KEY NOT NULL,
"name" varchar(255),
"email" varchar(255) NOT NULL,
"emailVerified" timestamp,
"image" varchar(255),
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "verificationToken" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT verificationToken_identifier_token PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_members" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"userId" uuid NOT NULL,
"workspaceId" bigint NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"role" "role" NOT NULL,
CONSTRAINT "workspace_members_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"name" varchar(255) NOT NULL,
"slug" varchar(255) NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "workspace_publicId_unique" UNIQUE("publicId"),
CONSTRAINT "workspace_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_importId_import_id_fk" FOREIGN KEY ("importId") REFERENCES "import"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "workspace_members"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_listId_list_id_fk" FOREIGN KEY ("listId") REFERENCES "list"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_importId_import_id_fk" FOREIGN KEY ("importId") REFERENCES "import"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "label"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "import" ADD CONSTRAINT "import_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "board"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_importId_import_id_fk" FOREIGN KEY ("importId") REFERENCES "import"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "board"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_importId_import_id_fk" FOREIGN KEY ("importId") REFERENCES "import"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "board" ALTER COLUMN "deletedAt" DROP DEFAULT;

View File

@@ -0,0 +1,66 @@
DROP TABLE IF EXISTS "account";--> statement-breakpoint
DROP TABLE IF EXISTS "session";--> statement-breakpoint
DROP TABLE IF EXISTS "verificationToken";--> statement-breakpoint
ALTER TABLE "board" DROP CONSTRAINT "board_workspaceId_workspace_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "card" DROP CONSTRAINT "card_listId_list_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT IF EXISTS "_card_labels_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT IF EXISTS "_card_labels_labelId_label_id_fk";
--> statement-breakpoint
ALTER TABLE "label" DROP CONSTRAINT "label_boardId_board_id_fk";
--> statement-breakpoint
ALTER TABLE "list" DROP CONSTRAINT "list_boardId_board_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_workspaceId_workspace_id_fk";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "workspace_members"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_listId_list_id_fk" FOREIGN KEY ("listId") REFERENCES "list"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "label"("id") ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "board"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "board"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,22 @@
DO $$ BEGIN
CREATE TYPE "member_status" AS ENUM('invited', 'active', 'removed');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_members" ADD COLUMN "status" "member_status" DEFAULT 'invited' NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "workspace_members" ADD COLUMN "deletedBy" uuid;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,55 @@
DO $$ BEGIN
CREATE TYPE "card_activity_type" AS ENUM('card.created', 'card.updated.title', 'card.updated.description', 'card.updated.index', 'card.updated.list', 'card.updated.label.added', 'card.updated.label.removed', 'card.updated.member.added', 'card.updated.member.removed', 'card.archived');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "card_activity" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"type" "card_activity_type" NOT NULL,
"cardId" bigint NOT NULL,
"fromIndex" integer,
"toIndex" integer,
"fromListId" bigint,
"toListId" bigint,
"labelId" bigint,
"workspaceMemberId" bigint,
"fromTitle" varchar(255),
"toTitle" varchar(255),
"fromDescription" text,
"toDescription" text,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "card_activity_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_toListId_list_id_fk" FOREIGN KEY ("toListId") REFERENCES "list"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "label"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "workspace_members"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,5 @@
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_fromListId_list_id_fk" FOREIGN KEY ("fromListId") REFERENCES "list"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS "card_comments" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"comment" text NOT NULL,
"cardId" bigint NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "card_comments_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,11 @@
ALTER TYPE "card_activity_type" ADD VALUE 'card.updated.comment.added';--> statement-breakpoint
ALTER TYPE "card_activity_type" ADD VALUE 'card.updated.comment.updated';--> statement-breakpoint
ALTER TYPE "card_activity_type" ADD VALUE 'card.updated.comment.deleted';--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "commentId" bigint;--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "fromComment" text;--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "toComment" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "card_comments"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
{
"version": "12",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1711571659259,
"tag": "0000_legal_quicksilver",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1713021051974,
"tag": "0001_stale_mattie_franklin",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1724967733894,
"tag": "0002_clever_robin_chapel",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1728246215706,
"tag": "0003_naive_secret_warriors",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1730205607613,
"tag": "0004_rainy_archangel",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1730813108528,
"tag": "0005_blue_marvex",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1730967400524,
"tag": "0006_neat_korg",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1731934769875,
"tag": "0007_adorable_crystal",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1731958265600,
"tag": "0008_nasty_bloodstorm",
"breakpoints": true
}
]
}

58
packages/db/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "@kan/db",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
"./client": {
"types": "./dist/client.d.ts",
"default": "./src/client.ts"
},
"./schema": {
"types": "./dist/schema.d.ts",
"default": "./src/schema.ts"
},
"./types/*": {
"types": "./dist/types/*.d.ts",
"default": "./src/types/*"
},
"./repository/*": {
"types": "./dist/repository/*.d.ts",
"default": "./src/repository/*.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",
"push": "pnpm with-env drizzle-kit push",
"studio": "pnpm with-env drizzle-kit studio",
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@kan/utils": "workspace:^",
"@vercel/postgres": "^0.10.0",
"drizzle-orm": "^0.36.4",
"drizzle-zod": "^0.5.1",
"zod": "catalog:"
},
"devDependencies": {
"@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"dotenv-cli": "^7.4.4",
"drizzle-kit": "^0.28.1",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

346
packages/db/seed.sql Normal file
View File

@@ -0,0 +1,346 @@
CREATE OR REPLACE FUNCTION reorder_lists(board_id BIGINT, list_id BIGINT, current_index INT, new_index INT)
RETURNS BOOLEAN
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE list
SET index =
CASE
WHEN index = current_index AND id = list_id THEN new_index
WHEN current_index < new_index AND index > current_index AND index <= new_index THEN index - 1
WHEN current_index > new_index AND index >= new_index AND index < current_index THEN index + 1
ELSE index
END
WHERE "boardId" = board_id;
-- Check for duplicate indices after the update
IF EXISTS (
SELECT index, COUNT(*)
FROM list
WHERE "boardId" = board_id
AND "deletedAt" IS NULL
GROUP BY index
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'Duplicate indices found after reordering in board %', board_id;
END IF;
RETURN TRUE;
END;
$$;
CREATE OR REPLACE FUNCTION reorder_cards(card_id BIGINT, current_list_id BIGINT, new_list_id BIGINT, current_index INT, new_index INT)
RETURNS BOOLEAN
LANGUAGE PLPGSQL
AS $$
DECLARE
card_index INT;
BEGIN
SELECT index INTO card_index FROM card WHERE "listId" = current_list_id AND id = card_id AND "deletedAt" IS NULL;
IF current_list_id = new_list_id THEN
UPDATE card
SET index =
CASE
WHEN index = current_index THEN new_index
WHEN current_index < new_index AND index > current_index AND index <= new_index THEN index - 1
WHEN current_index > new_index AND index >= new_index AND index < current_index THEN index + 1
ELSE index
END
WHERE "listId" = current_list_id AND "deletedAt" IS NULL;
ELSE
UPDATE card
SET index = index + 1
WHERE "listId" = new_list_id AND index >= new_index AND "deletedAt" IS NULL;
UPDATE card
SET index = index - 1
WHERE "listId" = current_list_id AND index >= current_index AND "deletedAt" IS NULL;
UPDATE card
SET "listId" = new_list_id, index = new_index
WHERE id = card_id AND "deletedAt" IS NULL;
END IF;
-- Check for duplicate indices in both affected lists
IF EXISTS (
SELECT index, COUNT(*)
FROM card
WHERE "listId" IN (current_list_id, new_list_id)
AND "deletedAt" IS NULL
GROUP BY "listId", index
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'Duplicate indices found after reordering in list % or %', current_list_id, new_list_id;
END IF;
RETURN TRUE;
END;
$$;
CREATE OR REPLACE FUNCTION shift_list_index(board_id BIGINT, list_index INT)
RETURNS VOID
LANGUAGE SQL
AS $$
UPDATE list
SET index = index - 1
WHERE "boardId" = board_id AND index > list_index AND "deletedAt" IS NULL;
$$;
CREATE OR REPLACE FUNCTION shift_card_index(list_id BIGINT, card_index INT)
RETURNS VOID
LANGUAGE SQL
AS $$
UPDATE card
SET index = index - 1
WHERE "listId" = list_id AND index > card_index AND "deletedAt" IS NULL;
$$;
CREATE OR REPLACE FUNCTION push_card_index(list_id BIGINT, card_index INT)
RETURNS VOID
LANGUAGE SQL
AS $$
UPDATE card
SET index = index + 1
WHERE "listId" = list_id AND index >= card_index AND "deletedAt" IS NULL;
$$;
CREATE OR REPLACE FUNCTION is_workspace_admin(user_id UUID, workspace_id BIGINT)
RETURNS BOOLEAN
LANGUAGE SQL
AS $$
SELECT EXISTS (
SELECT 1
FROM workspace_members
WHERE "workspaceId" = workspace_id
AND "userId" = user_id
AND "role" = 'admin'
);
$$;
alter table "_card_labels" enable row level security;
alter table "_card_workspace_members" enable row level security;
alter table "board" enable row level security;
alter table "card" enable row level security;
alter table "import" enable row level security;
alter table "label" enable row level security;
alter table "user" enable row level security;
alter table "list" enable row level security;
alter table "workspace" enable row level security;
alter table "workspace_members" enable row level security;
CREATE POLICY "Allow access to boards in user's workspace"
ON public.board
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"workspaceId" IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
);
CREATE POLICY "Allow access to lists in user's workspace"
ON public.list
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"boardId" IN (
SELECT b.id
FROM board b
JOIN workspace_members wm ON b."workspaceId" = wm."workspaceId"
WHERE wm."userId" = auth.uid()
)
);
CREATE POLICY "Allow access to cards in user's workspace"
ON public.card
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"listId" IN (
SELECT l.id
FROM list l
JOIN board b ON l."boardId" = b."id"
JOIN workspace_members wm ON b."workspaceId" = wm."workspaceId"
WHERE wm."userId" = auth.uid()
)
);
CREATE POLICY "Allow access to labels in user's workspace"
ON public.label
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"boardId" IN (
SELECT b.id
FROM board b
JOIN workspace_members wm ON b."workspaceId" = wm."workspaceId"
WHERE wm."userId" = auth.uid()
)
);
CREATE POLICY "Allow access to card labels in user's workspace"
ON public._card_labels
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"cardId" IN (
SELECT c.id
FROM card c
JOIN list l ON c."listId" = l.id
JOIN board b ON l."boardId" = b.id
JOIN workspace_members wm ON b."workspaceId" = wm."workspaceId"
WHERE wm."userId" = auth.uid()
)
AND
"labelId" IN (
SELECT l.id
FROM label l
JOIN board b ON l."boardId" = b.id
JOIN workspace_members wm ON b."workspaceId" = wm."workspaceId"
WHERE wm."userId" = auth.uid()
)
);
CREATE POLICY "Allow access to card workspace members in user's workspace"
ON public._card_workspace_members
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"cardId" IN (
SELECT c.id
FROM card c
JOIN list l ON c."listId" = l.id
JOIN board b ON l."boardId" = b.id
JOIN workspace_members wm ON b."workspaceId" = wm."workspaceId"
WHERE wm."userId" = auth.uid()
)
AND
"workspaceMemberId" IN (
SELECT wm.id
FROM workspace_members wm
JOIN workspace w ON wm."workspaceId" = w.id
JOIN board b ON w.id = b."workspaceId"
WHERE wm."userId" = auth.uid()
)
);
CREATE POLICY "Allow viewing members in user's workspace"
ON public.user
AS PERMISSIVE
FOR SELECT
TO authenticated
USING (
id IN (
SELECT wm."userId"
FROM workspace_members wm
WHERE wm."workspaceId" IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
)
);
CREATE POLICY "Allow viewing user's workspaces"
ON public.workspace
AS PERMISSIVE
FOR SELECT
TO authenticated
USING (
id IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
OR
"createdBy" = auth.uid()
);
CREATE POLICY "Allow updating user's workspaces"
ON public.workspace
AS PERMISSIVE
FOR UPDATE
TO authenticated
USING (
id IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
);
CREATE POLICY "Allow deleting user's workspaces"
ON public.workspace
AS PERMISSIVE
FOR DELETE
TO authenticated
USING (
id IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
);
CREATE POLICY "Allow authenticated users to create workspaces"
ON public.workspace
AS PERMISSIVE
FOR INSERT
TO authenticated
USING (true);
CREATE POLICY "Allow members to view workspace membership"
ON public.workspace_members
AS PERMISSIVE
FOR SELECT
TO authenticated
USING (
"userId" = auth.uid() OR
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow admins to add workspace members"
ON public.workspace_members
AS PERMISSIVE
FOR INSERT
TO authenticated
WITH CHECK (
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow admins to update workspace members"
ON public.workspace_members
AS PERMISSIVE
FOR UPDATE
TO authenticated
USING (
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow admins to remove workspace members"
ON public.workspace_members
AS PERMISSIVE
FOR DELETE
TO authenticated
USING (
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow access to user's own imports"
ON public.import
AS PERMISSIVE
FOR ALL
TO authenticated
USING (
"createdBy" = auth.uid()
);

View File

@@ -0,0 +1,9 @@
import { sql } from "@vercel/postgres";
import { drizzle } from "drizzle-orm/vercel-postgres";
import * as schema from "./schema";
export const db = drizzle({
client: sql,
schema,
});

View File

@@ -0,0 +1,15 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const postgresUrl = process.env.POSTGRES_URL;
if (!postgresUrl) {
throw new Error("POSTGRES_URL environment variable is not set");
}
const migrationClient = postgres(postgresUrl, { max: 1 });
migrate(drizzle(migrationClient), {
migrationsFolder: "./src/server/db/migrations",
}).catch((e) => console.log(e));

View File

@@ -0,0 +1,191 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const getAllByWorkspaceId = async (
db: SupabaseClient<Database>,
workspaceId: number,
) => {
const { data } = await db
.from("board")
.select(`publicId, name`)
.is("deletedAt", null)
.eq("workspaceId", workspaceId);
return data ?? [];
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
boardPublicId: string,
filters: {
members: string[];
labels: string[];
},
) => {
let query = db
.from("board")
.select(
`
publicId,
name,
workspace (
publicId,
members:workspace_members (
publicId,
user!workspace_members_userId_user_id_fk (
name
)
)
),
labels:label (
publicId,
name,
colourCode
),
lists:list (
publicId,
name,
boardId,
index,
cards:card (
publicId,
title,
description,
listId,
index,
labels:label${filters.labels.length > 0 ? "!inner" : ""} (
publicId,
name,
colourCode
),
members:workspace_members${filters.members.length > 0 ? "!inner" : ""} (
publicId,
user!workspace_members_userId_user_id_fk (
name
)
)
)
)
`,
)
.eq("publicId", boardPublicId)
.is("deletedAt", null)
.is("lists.deletedAt", null)
.is("lists.cards.deletedAt", null)
.is("workspace.members.deletedAt", null)
.is("lists.cards.members.deletedAt", null);
if (filters.labels.length > 0) {
query = query.in("lists.cards.labels.publicId", filters.labels);
}
if (filters.members.length > 0) {
query = query.in("lists.cards.members.publicId", filters.members);
}
const { data } = await query
.order("index", { foreignTable: "list", ascending: true })
.order("index", { foreignTable: "list.card", ascending: true })
.limit(1)
.single();
return data;
};
export const getWithListIdsByPublicId = async (
db: SupabaseClient<Database>,
boardPublicId: string,
) => {
const { data } = await db
.from("board")
.select(`id, lists:list (id)`)
.eq("publicId", boardPublicId)
.limit(1)
.single();
return data;
};
export const getWithLatestListIndexByPublicId = async (
db: SupabaseClient<Database>,
boardPublicId: string,
) => {
const { data } = await db
.from("board")
.select(`id, lists:list (index)`)
.eq("publicId", boardPublicId)
.order("index", { foreignTable: "list", ascending: false })
.is("list.deletedAt", null)
.limit(1)
.single();
return data;
};
export const create = async (
db: SupabaseClient<Database>,
boardInput: {
name: string;
createdBy: string;
workspaceId: number;
importId?: number;
},
) => {
const { data } = await db
.from("board")
.insert({
publicId: generateUID(),
name: boardInput.name,
createdBy: boardInput.createdBy,
workspaceId: boardInput.workspaceId,
importId: boardInput.importId,
})
.select(`id, publicId, name`)
.limit(1)
.single();
return data;
};
export const update = async (
db: SupabaseClient<Database>,
boardInput: { name: string; boardPublicId: string },
) => {
const { data } = await db
.from("board")
.update({ name: boardInput.name })
.eq("publicId", boardInput.boardPublicId)
.select(`publicId, name`)
.limit(1)
.single();
return data;
};
export const softDelete = async (
db: SupabaseClient<Database>,
args: {
boardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = db
.from("board")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.boardId)
.is("deletedAt", null);
return result;
};
export const hardDelete = async (
db: SupabaseClient<Database>,
workspaceId: number,
) => {
const result = db.from("board").delete().eq("workspaceId", workspaceId);
return result;
};

View File

@@ -0,0 +1,426 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
cardInput: {
title: string;
description: string;
createdBy: string;
listId: number;
index: number;
},
) => {
const { data } = await db
.from("card")
.insert({
publicId: generateUID(),
title: cardInput.title,
description: cardInput.description,
createdBy: cardInput.createdBy,
listId: cardInput.listId,
index: cardInput.index,
})
.select(`id`)
.limit(1)
.single();
return data;
};
export const bulkCreateCardLabelRelationships = async (
db: SupabaseClient<Database>,
cardLabelRelationshipInput: {
cardId: number;
labelId: number;
}[],
) => {
const { data } = await db
.from("_card_labels")
.insert(cardLabelRelationshipInput)
.select();
return data;
};
export const bulkCreateCardWorkspaceMemberRelationships = async (
db: SupabaseClient<Database>,
cardWorkspaceMemberRelationshipInput: {
cardId: number;
workspaceMemberId: number;
}[],
) => {
const { data } = await db
.from("_card_workspace_members")
.insert(cardWorkspaceMemberRelationshipInput)
.select();
return data;
};
export const update = async (
db: SupabaseClient<Database>,
cardInput: {
title: string;
description: string;
},
args: {
cardPublicId: string;
},
) => {
const { data } = await db
.from("card")
.update({ title: cardInput.title, description: cardInput.description })
.eq("publicId", args.cardPublicId)
.is("deletedAt", null)
.select(`id, publicId, title, description`)
.order("id", { ascending: true })
.limit(1)
.single();
return data;
};
export const getCardWithListByPublicId = async (
db: SupabaseClient<Database>,
cardPublicId: string,
) => {
const { data } = await db
.from("card")
.select(`id, index, list (id, boardId)`)
.eq("publicId", cardPublicId)
.is("deletedAt", null)
.limit(1)
.single();
return data;
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
cardPublicId: string,
) => {
const { data } = await db
.from("card")
.select(`id, publicId, title, description`)
.eq("publicId", cardPublicId)
.limit(1)
.single();
return data;
};
export const getCardLabelRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; labelId: number },
) => {
const { data } = await db
.from("_card_labels")
.select()
.eq("cardId", args.cardId)
.eq("labelId", args.labelId)
.limit(1)
.single();
return data;
};
export const bulkCreate = async (
db: SupabaseClient<Database>,
cardInput: {
publicId: string;
title: string;
description: string;
createdBy: string;
listId: number;
index: number;
importId?: number;
}[],
) => {
const { data } = await db.from("card").insert(cardInput).select(`id`);
return data;
};
export const createCardLabelRelationship = async (
db: SupabaseClient<Database>,
cardLabelRelationshipInput: { cardId: number; labelId: number },
) => {
const { data } = await db
.from("_card_labels")
.insert({
cardId: cardLabelRelationshipInput.cardId,
labelId: cardLabelRelationshipInput.labelId,
})
.select()
.limit(1)
.single();
return data;
};
export const getCardMemberRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; memberId: number },
) => {
const { data } = await db
.from("_card_workspace_members")
.select()
.eq("cardId", args.cardId)
.eq("workspaceMemberId", args.memberId)
.limit(1)
.single();
return data;
};
export const createCardMemberRelationship = async (
db: SupabaseClient<Database>,
cardMemberRelationshipInput: { cardId: number; memberId: number },
) => {
const { error } = await db.from("_card_workspace_members").insert({
cardId: cardMemberRelationshipInput.cardId,
workspaceMemberId: cardMemberRelationshipInput.memberId,
});
return { success: !error };
};
export const getWithListAndMembersByPublicId = async (
db: SupabaseClient<Database>,
cardPublicId: string,
) => {
const { data } = await db
.from("card")
.select(
`
publicId,
title,
description,
labels:label (
publicId,
name,
colourCode
),
list (
publicId,
name,
board (
publicId,
name,
labels:label (
publicId,
colourCode,
name
),
lists:list (
publicId,
name
),
workspace (
publicId,
members:workspace_members (
publicId,
user!workspace_members_userId_user_id_fk (
id,
name
)
)
)
)
),
members:workspace_members (
publicId,
user!workspace_members_userId_user_id_fk (
id,
name
)
),
activities:card_activity (
publicId,
type,
createdAt,
fromIndex,
toIndex,
fromTitle,
toTitle,
fromDescription,
toDescription,
fromList:list!card_activity_fromListId_list_id_fk (
publicId,
name,
index
),
toList:list!card_activity_toListId_list_id_fk (
publicId,
name,
index
),
label!card_activity_labelId_label_id_fk (
publicId,
name
),
member:workspace_members!card_activity_workspaceMemberId_workspace_members_id_fk (
publicId,
user!workspace_members_userId_user_id_fk (
id,
name,
email
)
),
user!card_activity_createdBy_user_id_fk (
id,
name,
email
),
comment:card_comments!card_activity_commentId_card_comments_id_fk (
publicId,
comment,
createdBy,
updatedAt
)
)
`,
)
.eq("publicId", cardPublicId)
.is("deletedAt", null)
.is("list.board.lists.deletedAt", null)
.is("list.board.workspace.members.deletedAt", null)
.is("members.deletedAt", null)
.limit(1)
.single();
return data;
};
export const reorder = async (
db: SupabaseClient<Database>,
args: {
currentListId: number;
newListId: number;
currentIndex: number;
newIndex: number;
cardId: number;
},
) => {
const { error } = await db.rpc("reorder_cards", {
current_list_id: args.currentListId,
new_list_id: args.newListId,
current_index: args.currentIndex,
new_index: args.newIndex,
card_id: args.cardId,
});
return { success: !error };
};
export const shiftIndex = async (
db: SupabaseClient<Database>,
args: {
listId: number;
cardIndex: number;
},
) => {
const { data } = await db.rpc("shift_card_index", {
list_id: args.listId,
card_index: args.cardIndex,
});
return data;
};
export const pushIndex = async (
db: SupabaseClient<Database>,
args: {
listId: number;
cardIndex: number;
},
) => {
const { data } = await db.rpc("push_card_index", {
list_id: args.listId,
card_index: args.cardIndex,
});
return data;
};
export const softDelete = async (
db: SupabaseClient<Database>,
args: {
cardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const { data } = await db
.from("card")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.cardId)
.select(`id`)
.order("id", { ascending: true })
.limit(1)
.single();
return data;
};
export const softDeleteAllByListIds = async (
db: SupabaseClient<Database>,
args: {
listIds: number[];
deletedAt: string;
deletedBy: string;
},
) => {
const { data } = await db
.from("card")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.in("listId", args.listIds)
.is("deletedAt", null)
.select(`id`);
return data;
};
export const hardDeleteCardMemberRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; memberId: number },
) => {
const { error } = await db
.from("_card_workspace_members")
.delete()
.eq("cardId", args.cardId)
.eq("workspaceMemberId", args.memberId)
.select()
.order("cardId", { ascending: true })
.limit(1)
.single();
return { success: !error };
};
export const hardDeleteCardLabelRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; labelId: number },
) => {
const { data } = await db
.from("_card_labels")
.delete()
.eq("cardId", args.cardId)
.eq("labelId", args.labelId)
.select()
.single();
return { data };
};
export const hardDeleteAllCardLabelRelationships = async (
db: SupabaseClient<Database>,
labelId: number,
) => {
const result = await db.from("_card_labels").delete().eq("labelId", labelId);
return result;
};

View File

@@ -0,0 +1,84 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
activityInput: {
type: Database["public"]["Enums"]["card_activity_type"];
cardId: number;
fromIndex?: number;
toIndex?: number;
fromListId?: number;
toListId?: number;
labelId?: number;
workspaceMemberId?: number;
fromTitle?: string;
toTitle?: string;
fromDescription?: string;
toDescription?: string;
createdBy: string;
commentId?: number;
fromComment?: string;
toComment?: string;
},
) => {
const { data } = await db
.from("card_activity")
.insert({
publicId: generateUID(),
type: activityInput.type,
cardId: activityInput.cardId,
fromListId: activityInput.fromListId,
toListId: activityInput.toListId,
fromIndex: activityInput.fromIndex,
toIndex: activityInput.toIndex,
labelId: activityInput.labelId,
workspaceMemberId: activityInput.workspaceMemberId,
fromTitle: activityInput.fromTitle,
toTitle: activityInput.toTitle,
fromDescription: activityInput.fromDescription,
toDescription: activityInput.toDescription,
createdBy: activityInput.createdBy,
commentId: activityInput.commentId,
fromComment: activityInput.fromComment,
toComment: activityInput.toComment,
})
.select(`id`)
.limit(1)
.single();
return data;
};
export const bulkCreate = async (
db: SupabaseClient<Database>,
activityInputs: {
type: Database["public"]["Enums"]["card_activity_type"];
cardId: number;
fromIndex?: number;
toIndex?: number;
fromListId?: number;
toListId?: number;
labelId?: number;
workspaceMemberId?: number;
fromTitle?: string;
toTitle?: string;
fromDescription?: string;
toDescription?: string;
createdBy: string;
}[],
) => {
const activitiesWithPublicIds = activityInputs.map((activity) => ({
...activity,
publicId: generateUID(),
}));
const { data } = await db
.from("card_activity")
.insert(activitiesWithPublicIds)
.select("id");
return data;
};

View File

@@ -0,0 +1,63 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
commentInput: {
cardId: number;
comment: string;
createdBy: string;
},
) => {
const { data } = await db
.from("card_comments")
.insert({
publicId: generateUID(),
comment: commentInput.comment,
createdBy: commentInput.createdBy,
cardId: commentInput.cardId,
})
.select(`id, publicId, comment`)
.limit(1)
.single();
return data;
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
publicId: string,
) => {
const { data } = await db
.from("card_comments")
.select(`id, publicId, comment, createdBy`)
.eq("publicId", publicId)
.limit(1)
.single();
return data;
};
export const update = async (
db: SupabaseClient<Database>,
commentInput: {
id: number;
comment: string;
},
) => {
const { data } = await db
.from("card_comments")
.update({
comment: commentInput.comment,
updatedAt: new Date().toISOString(),
})
.eq("id", commentInput.id)
.select(`id, publicId, comment`)
.limit(1)
.order("id", { ascending: false })
.single();
return data;
};

View File

@@ -0,0 +1,38 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
importInput: { source: string; createdBy: string },
) => {
const { data } = await db
.from("import")
.insert({
publicId: generateUID(),
source: "trello",
createdBy: importInput.createdBy,
status: "started",
})
.select(`id`)
.limit(1)
.single();
return data;
};
export const update = async (
db: SupabaseClient<Database>,
importInput: { status: "started" | "success" | "failed" },
args: { importId: number },
) => {
const { data } = await db
.from("import")
.update({ status: importInput.status })
.eq("importId", args.importId)
.limit(1)
.single();
return data;
};

View File

@@ -0,0 +1,90 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
labelInput: {
name: string;
colourCode: string;
createdBy: string;
boardId: number;
cardId?: number;
},
) => {
const { data } = await db
.from("label")
.insert({
publicId: generateUID(),
name: labelInput.name,
colourCode: labelInput.colourCode,
createdBy: labelInput.createdBy,
boardId: labelInput.boardId,
})
.select(`id`)
.limit(1)
.single();
if (labelInput.cardId && data)
await db.from("_card_labels").insert({
cardId: labelInput.cardId,
labelId: data.id,
});
return data;
};
export const getAllByPublicIds = async (
db: SupabaseClient<Database>,
labelPublicIds: string[],
) => {
const { data } = await db
.from("label")
.select(`id`)
.in("publicId", labelPublicIds);
return data;
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
labelPublicId: string,
) => {
const { data } = await db
.from("label")
.select(`id, publicId, name, colourCode`)
.eq("publicId", labelPublicId)
.limit(1)
.single();
return data;
};
export const update = async (
db: SupabaseClient<Database>,
labelInput: {
labelPublicId: string;
name: string;
colourCode: string;
},
) => {
const { data } = await db
.from("label")
.update({
name: labelInput.name,
colourCode: labelInput.colourCode,
})
.eq("publicId", labelInput.labelPublicId);
return data;
};
export const hardDelete = async (
db: SupabaseClient<Database>,
labelId: number,
) => {
const { data } = await db.from("label").delete().eq("id", labelId);
return data;
};

View File

@@ -0,0 +1,161 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
listInput: {
name: string;
createdBy: string;
boardId: number;
index: number;
importId?: number;
},
) => {
const { data } = await db
.from("list")
.insert({
publicId: generateUID(),
name: listInput.name,
createdBy: listInput.createdBy,
boardId: listInput.boardId,
index: listInput.index,
importId: listInput.importId,
})
.select(
`
id,
publicId,
name
`,
)
.limit(1)
.single();
return data;
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
listPublicId: string,
) => {
const { data } = await db
.from("list")
.select(`id, boardId, index`)
.eq("publicId", listPublicId)
.limit(1)
.single();
return data;
};
export const getWithCardsByPublicId = async (
db: SupabaseClient<Database>,
listPublicId: string,
) => {
const { data } = await db
.from("list")
.select(`id, cards:card (index)`)
.eq("publicId", listPublicId)
.is("deletedAt", null)
.is("card.deletedAt", null)
.order("index", { foreignTable: "card", ascending: false })
.limit(1)
.single();
return data;
};
export const update = async (
db: SupabaseClient<Database>,
listInput: {
name: string;
},
args: {
listPublicId: string;
},
) => {
const { data } = await db
.from("list")
.update({ name: listInput.name })
.eq("publicId", args.listPublicId)
.is("deletedAt", null)
.select(`publicId, name`);
return data;
};
export const reorder = async (
db: SupabaseClient<Database>,
args: {
boardPublicId: number;
listPublicId: number;
currentIndex: number;
newIndex: number;
},
) => {
const { data } = await db.rpc("reorder_lists", {
board_id: args.boardPublicId,
list_id: args.listPublicId,
current_index: args.currentIndex,
new_index: args.newIndex,
});
return data;
};
export const shiftIndex = async (
db: SupabaseClient<Database>,
args: {
boardId: number;
listIndex: number;
},
) => {
const { data } = await db.rpc("shift_list_index", {
board_id: args.boardId,
list_index: args.listIndex,
});
return data;
};
export const softDeleteAllByBoardId = async (
db: SupabaseClient<Database>,
args: {
boardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const { data } = await db
.from("list")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("boardId", args.boardId)
.is("deletedAt", null)
.select(`id`)
.order("id", { ascending: true });
return data;
};
export const softDeleteById = async (
db: SupabaseClient<Database>,
args: {
listId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const { data } = await db
.from("list")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.listId)
.is("deletedAt", null)
.select(`id`)
.order("id", { ascending: true })
.limit(1)
.single();
return data;
};

View File

@@ -0,0 +1,74 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
memberInput: {
userId: string;
workspaceId: number;
createdBy: string;
role: "admin" | "member" | "guest";
status: "invited" | "active" | "removed";
},
) => {
const { data } = await db
.from("workspace_members")
.insert({
publicId: generateUID(),
userId: memberInput.userId,
workspaceId: memberInput.workspaceId,
createdBy: memberInput.createdBy,
role: memberInput.role,
status: memberInput.status,
})
.select(`id, publicId`)
.limit(1)
.single();
return data;
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
publicId: string,
) => {
const { data } = await db
.from("workspace_members")
.select()
.eq("publicId", publicId)
.limit(1)
.single();
return data;
};
export const acceptInvite = async (
db: SupabaseClient<Database>,
id: number,
) => {
const { data } = await db
.from("workspace_members")
.update({ status: "active" })
.eq("id", id);
return data;
};
export const softDelete = async (
db: SupabaseClient<Database>,
args: {
memberId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("workspace_members")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.memberId)
.is("deletedAt", null);
return result;
};

View File

@@ -0,0 +1,42 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
export const getById = async (db: SupabaseClient<Database>, userId: string) => {
const { data } = await db
.from("user")
.select(`id, name, email`)
.eq("id", userId)
.limit(1)
.single();
return data;
};
export const getByEmail = async (
db: SupabaseClient<Database>,
email: string,
) => {
const { data } = await db
.from("user")
.select(`id, name, email`)
.eq("email", email)
.limit(1)
.single();
return data;
};
export const create = async (
db: SupabaseClient<Database>,
user: { id: string; email: string },
) => {
const { data } = await db
.from("user")
.insert({ id: user.id, email: user.email })
.select()
.limit(1)
.single();
return data;
};

View File

@@ -0,0 +1,159 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
import { generateUID } from "@kan/utils";
export const create = async (
db: SupabaseClient<Database>,
workspaceInput: {
name: string;
slug: string;
createdBy: string;
},
) => {
const { data } = await db
.from("workspace")
.insert({
publicId: generateUID(),
name: workspaceInput.name,
slug: workspaceInput.name.toLowerCase(),
createdBy: workspaceInput.createdBy,
})
.select(`id, publicId, name`)
.limit(1)
.single();
if (data)
await db.from("workspace_members").insert({
publicId: generateUID(),
userId: workspaceInput.createdBy,
workspaceId: data.id,
createdBy: workspaceInput.createdBy,
role: "admin",
});
const newWorkspace = { ...data };
delete newWorkspace.id;
return newWorkspace;
};
export const update = async (
db: SupabaseClient<Database>,
workspacePublicId: string,
name: string,
) => {
const { data } = await db
.from("workspace")
.update({ name })
.eq("publicId", workspacePublicId)
.is("deletedAt", null);
return data;
};
export const getByPublicId = async (
db: SupabaseClient<Database>,
workspacePublicId: string,
) => {
const { data } = await db
.from("workspace")
.select(`id, publicId, name`)
.is("deletedAt", null)
.eq("publicId", workspacePublicId)
.limit(1)
.single();
return data;
};
export const getByPublicIdWithMembers = async (
db: SupabaseClient<Database>,
workspacePublicId: string,
) => {
const { data } = await db
.from("workspace")
.select(
`
id,
publicId,
members: workspace_members (
publicId,
role,
status,
user!workspace_members_userId_user_id_fk (
id,
name,
email
)
)
`,
)
.eq("publicId", workspacePublicId)
.is("deletedAt", null)
.is("members.deletedAt", null)
.limit(1)
.single();
return data;
};
export const getAllByUserId = async (
db: SupabaseClient<Database>,
userId: string,
) => {
const { data } = await db
.from("workspace_members")
.select(
`
role,
workspace (
publicId,
name
)
`,
)
.eq("userId", userId)
.is("deletedAt", null);
return data ?? [];
};
export const getMemberByPublicId = async (
db: SupabaseClient<Database>,
memberPublicId: string,
) => {
const { data } = await db
.from("workspace_members")
.select(`id`)
.eq("publicId", memberPublicId)
.limit(1)
.single();
return data;
};
export const getAllMembersByPublicIds = async (
db: SupabaseClient<Database>,
memberPublicIds: string[],
) => {
const { data } = await db
.from("workspace_members")
.select(`id`)
.eq("publicId", memberPublicIds);
return data;
};
export const hardDelete = async (
db: SupabaseClient<Database>,
workspacePublicId: string,
) => {
const result = db
.from("workspace")
.delete()
.eq("publicId", workspacePublicId);
return result;
};

432
packages/db/src/schema.ts Normal file
View File

@@ -0,0 +1,432 @@
import { relations } from "drizzle-orm";
import {
integer,
bigserial,
uuid,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
varchar,
bigint,
} from "drizzle-orm/pg-core";
export const importSourceEnum = pgEnum("source", ["trello"]);
export const importStatusEnum = pgEnum("status", [
"started",
"success",
"failed",
]);
export const memberRoleEnum = pgEnum("role", ["admin", "member", "guest"]);
export const memberStatusEnum = pgEnum("member_status", [
"invited",
"active",
"removed",
]);
export const activityTypeEnum = pgEnum("card_activity_type", [
"card.created",
"card.updated.title",
"card.updated.description",
"card.updated.index",
"card.updated.list",
"card.updated.label.added",
"card.updated.label.removed",
"card.updated.member.added",
"card.updated.member.removed",
"card.updated.comment.added",
"card.updated.comment.updated",
"card.updated.comment.deleted",
"card.archived",
]);
export const boards = pgTable("board", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
});
export const boardsRelations = relations(boards, ({ one, many }) => ({
createdBy: one(users, {
fields: [boards.createdBy],
references: [users.id],
}),
lists: many(lists),
labels: many(labels),
deletedBy: one(users, {
fields: [boards.deletedBy],
references: [users.id],
}),
import: one(imports, {
fields: [boards.importId],
references: [imports.id],
}),
workspace: one(workspaces, {
fields: [boards.workspaceId],
references: [workspaces.id],
}),
}));
export const imports = pgTable("import", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
source: importSourceEnum("source").notNull(),
status: importStatusEnum("status").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export const importsRelations = relations(imports, ({ one, many }) => ({
createdBy: one(users, {
fields: [imports.createdBy],
references: [users.id],
}),
boards: many(boards),
cards: many(cards),
lists: many(lists),
labels: many(labels),
}));
export const labels = pgTable("label", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
colourCode: varchar("colourCode", { length: 12 }),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
boardId: bigint("boardId", { mode: "number" })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
});
export const labelsRelations = relations(labels, ({ one, many }) => ({
createdBy: one(users, {
fields: [labels.createdBy],
references: [users.id],
}),
board: one(boards, {
fields: [labels.boardId],
references: [boards.id],
}),
cards: many(cardsToLabels),
import: one(imports, {
fields: [labels.importId],
references: [imports.id],
}),
}));
export const cardsToLabels = pgTable(
"_card_labels",
{
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id),
labelId: bigint("labelId", { mode: "number" })
.notNull()
.references(() => labels.id, { onDelete: "cascade" }),
},
(t) => ({
pk: primaryKey(t.cardId, t.labelId),
}),
);
export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({
card: one(cards, {
fields: [cardsToLabels.cardId],
references: [cards.id],
}),
label: one(labels, {
fields: [cardsToLabels.labelId],
references: [labels.id],
}),
}));
export const cardToWorkspaceMembers = pgTable(
"_card_workspace_members",
{
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id),
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
.notNull()
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
},
(t) => ({
pk: primaryKey(t.cardId, t.workspaceMemberId),
}),
);
export const cardToWorkspaceMembersRelations = relations(
cardToWorkspaceMembers,
({ one }) => ({
card: one(cards, {
fields: [cardToWorkspaceMembers.cardId],
references: [cards.id],
}),
member: one(workspaceMembers, {
fields: [cardToWorkspaceMembers.workspaceMemberId],
references: [workspaceMembers.id],
}),
}),
);
export const lists = pgTable("list", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
boardId: bigint("boardId", { mode: "number" })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
});
export const listsRelations = relations(lists, ({ one, many }) => ({
createdBy: one(users, {
fields: [lists.createdBy],
references: [users.id],
}),
board: one(boards, {
fields: [lists.boardId],
references: [boards.id],
}),
cards: many(cards),
deletedBy: one(users, {
fields: [lists.deletedBy],
references: [users.id],
}),
import: one(imports, {
fields: [lists.importId],
references: [imports.id],
}),
}));
export const cards = pgTable("card", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
index: integer("index").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
listId: bigint("listId", { mode: "number" })
.notNull()
.references(() => lists.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
});
export const cardsRelations = relations(cards, ({ one, many }) => ({
createdBy: one(users, {
fields: [cards.createdBy],
references: [users.id],
}),
list: one(lists, {
fields: [cards.listId],
references: [lists.id],
}),
deletedBy: one(users, {
fields: [cards.deletedBy],
references: [users.id],
}),
labels: many(cardsToLabels),
members: many(cardToWorkspaceMembers),
import: one(imports, {
fields: [cards.importId],
references: [imports.id],
}),
comments: many(comments),
}));
export const users = pgTable("user", {
id: uuid("id").notNull().primaryKey(),
name: varchar("name", { length: 255 }),
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: varchar("image", { length: 255 }),
});
export const usersRelations = relations(users, ({ many }) => ({
boards: many(boards),
cards: many(cards),
imports: many(imports),
lists: many(lists),
workspaces: many(workspaces),
}));
export const workspaces = pgTable("workspace", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
});
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
user: one(users, { fields: [workspaces.createdBy], references: [users.id] }),
members: many(workspaceMembers),
}));
export const workspaceMembers = pgTable("workspace_members", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
userId: uuid("userId")
.notNull()
.references(() => users.id),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
role: memberRoleEnum("role").notNull(),
status: memberStatusEnum("status").default("invited").notNull(),
});
export const usersToWorkspacesRelations = relations(
workspaceMembers,
({ one }) => ({
addedBy: one(users, {
fields: [workspaceMembers.createdBy],
references: [users.id],
}),
deletedBy: one(users, {
fields: [workspaceMembers.deletedBy],
references: [users.id],
}),
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
}),
workspace: one(workspaces, {
fields: [workspaceMembers.workspaceId],
references: [workspaces.id],
}),
}),
);
export const cardActivities = pgTable("card_activity", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
type: activityTypeEnum("type").notNull(),
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
fromIndex: integer("fromIndex"),
toIndex: integer("toIndex"),
fromListId: bigint("fromListId", { mode: "number" }).references(
() => lists.id,
),
toListId: bigint("toListId", { mode: "number" }).references(() => lists.id),
labelId: bigint("labelId", { mode: "number" }).references(() => labels.id),
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }).references(
() => workspaceMembers.id,
),
fromTitle: varchar("fromTitle", { length: 255 }),
toTitle: varchar("toTitle", { length: 255 }),
fromDescription: text("fromDescription"),
toDescription: text("toDescription"),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
commentId: bigint("commentId", { mode: "number" }).references(
() => comments.id,
),
fromComment: text("fromComment"),
toComment: text("toComment"),
});
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
card: one(cards, {
fields: [cardActivities.cardId],
references: [cards.id],
}),
fromList: one(lists, {
fields: [cardActivities.fromListId],
references: [lists.id],
}),
toList: one(lists, {
fields: [cardActivities.toListId],
references: [lists.id],
}),
label: one(labels, {
fields: [cardActivities.labelId],
references: [labels.id],
}),
workspaceMember: one(workspaceMembers, {
fields: [cardActivities.workspaceMemberId],
references: [workspaceMembers.id],
}),
createdBy: one(users, {
fields: [cardActivities.createdBy],
references: [users.id],
}),
}));
export const comments = pgTable("card_comments", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
comment: text("comment").notNull(),
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
});
export const commentsRelations = relations(comments, ({ one }) => ({
card: one(cards, {
fields: [comments.cardId],
references: [cards.id],
}),
createdBy: one(users, {
fields: [comments.createdBy],
references: [users.id],
}),
deletedBy: one(users, {
fields: [comments.deletedBy],
references: [users.id],
}),
}));

View File

@@ -0,0 +1,868 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[];
export type Database = {
public: {
Tables: {
_card_labels: {
Row: {
cardId: number;
labelId: number;
};
Insert: {
cardId: number;
labelId: number;
};
Update: {
cardId?: number;
labelId?: number;
};
Relationships: [
{
foreignKeyName: "_card_labels_cardId_card_id_fk";
columns: ["cardId"];
isOneToOne: false;
referencedRelation: "card";
referencedColumns: ["id"];
},
{
foreignKeyName: "_card_labels_labelId_label_id_fk";
columns: ["labelId"];
isOneToOne: false;
referencedRelation: "label";
referencedColumns: ["id"];
},
];
};
_card_workspace_members: {
Row: {
cardId: number;
workspaceMemberId: number;
};
Insert: {
cardId: number;
workspaceMemberId: number;
};
Update: {
cardId?: number;
workspaceMemberId?: number;
};
Relationships: [
{
foreignKeyName: "_card_workspace_members_cardId_card_id_fk";
columns: ["cardId"];
isOneToOne: false;
referencedRelation: "card";
referencedColumns: ["id"];
},
{
foreignKeyName: "_card_workspace_members_workspaceMemberId_workspace_members_id_";
columns: ["workspaceMemberId"];
isOneToOne: false;
referencedRelation: "workspace_members";
referencedColumns: ["id"];
},
];
};
board: {
Row: {
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
id: number;
importId: number | null;
name: string;
publicId: string;
updatedAt: string | null;
workspaceId: number;
};
Insert: {
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
importId?: number | null;
name: string;
publicId: string;
updatedAt?: string | null;
workspaceId: number;
};
Update: {
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
importId?: number | null;
name?: string;
publicId?: string;
updatedAt?: string | null;
workspaceId?: number;
};
Relationships: [
{
foreignKeyName: "board_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "board_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "board_importId_import_id_fk";
columns: ["importId"];
isOneToOne: false;
referencedRelation: "import";
referencedColumns: ["id"];
},
{
foreignKeyName: "board_workspaceId_workspace_id_fk";
columns: ["workspaceId"];
isOneToOne: false;
referencedRelation: "workspace";
referencedColumns: ["id"];
},
];
};
card: {
Row: {
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
description: string | null;
id: number;
importId: number | null;
index: number;
listId: number;
publicId: string;
title: string;
updatedAt: string | null;
};
Insert: {
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
description?: string | null;
id?: number;
importId?: number | null;
index: number;
listId: number;
publicId: string;
title: string;
updatedAt?: string | null;
};
Update: {
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
description?: string | null;
id?: number;
importId?: number | null;
index?: number;
listId?: number;
publicId?: string;
title?: string;
updatedAt?: string | null;
};
Relationships: [
{
foreignKeyName: "card_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_importId_import_id_fk";
columns: ["importId"];
isOneToOne: false;
referencedRelation: "import";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_listId_list_id_fk";
columns: ["listId"];
isOneToOne: false;
referencedRelation: "list";
referencedColumns: ["id"];
},
];
};
card_activity: {
Row: {
cardId: number;
commentId: number | null;
createdAt: string;
createdBy: string;
fromComment: string | null;
fromDescription: string | null;
fromIndex: number | null;
fromListId: number | null;
fromTitle: string | null;
id: number;
labelId: number | null;
publicId: string;
toComment: string | null;
toDescription: string | null;
toIndex: number | null;
toListId: number | null;
toTitle: string | null;
type: Database["public"]["Enums"]["card_activity_type"];
workspaceMemberId: number | null;
};
Insert: {
cardId: number;
commentId?: number | null;
createdAt?: string;
createdBy: string;
fromComment?: string | null;
fromDescription?: string | null;
fromIndex?: number | null;
fromListId?: number | null;
fromTitle?: string | null;
id?: number;
labelId?: number | null;
publicId: string;
toComment?: string | null;
toDescription?: string | null;
toIndex?: number | null;
toListId?: number | null;
toTitle?: string | null;
type: Database["public"]["Enums"]["card_activity_type"];
workspaceMemberId?: number | null;
};
Update: {
cardId?: number;
commentId?: number | null;
createdAt?: string;
createdBy?: string;
fromComment?: string | null;
fromDescription?: string | null;
fromIndex?: number | null;
fromListId?: number | null;
fromTitle?: string | null;
id?: number;
labelId?: number | null;
publicId?: string;
toComment?: string | null;
toDescription?: string | null;
toIndex?: number | null;
toListId?: number | null;
toTitle?: string | null;
type?: Database["public"]["Enums"]["card_activity_type"];
workspaceMemberId?: number | null;
};
Relationships: [
{
foreignKeyName: "card_activity_cardId_card_id_fk";
columns: ["cardId"];
isOneToOne: false;
referencedRelation: "card";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_commentId_card_comments_id_fk";
columns: ["commentId"];
isOneToOne: false;
referencedRelation: "card_comments";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_fromListId_list_id_fk";
columns: ["fromListId"];
isOneToOne: false;
referencedRelation: "list";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_labelId_label_id_fk";
columns: ["labelId"];
isOneToOne: false;
referencedRelation: "label";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_toListId_list_id_fk";
columns: ["toListId"];
isOneToOne: false;
referencedRelation: "list";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_workspaceMemberId_workspace_members_id_fk";
columns: ["workspaceMemberId"];
isOneToOne: false;
referencedRelation: "workspace_members";
referencedColumns: ["id"];
},
];
};
card_comments: {
Row: {
cardId: number;
comment: string;
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
id: number;
publicId: string;
updatedAt: string | null;
};
Insert: {
cardId: number;
comment: string;
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
publicId: string;
updatedAt?: string | null;
};
Update: {
cardId?: number;
comment?: string;
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
publicId?: string;
updatedAt?: string | null;
};
Relationships: [
{
foreignKeyName: "card_comments_cardId_card_id_fk";
columns: ["cardId"];
isOneToOne: false;
referencedRelation: "card";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_comments_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_comments_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
];
};
import: {
Row: {
createdAt: string;
createdBy: string;
id: number;
publicId: string;
source: Database["public"]["Enums"]["source"];
status: Database["public"]["Enums"]["status"];
};
Insert: {
createdAt?: string;
createdBy: string;
id?: number;
publicId: string;
source: Database["public"]["Enums"]["source"];
status: Database["public"]["Enums"]["status"];
};
Update: {
createdAt?: string;
createdBy?: string;
id?: number;
publicId?: string;
source?: Database["public"]["Enums"]["source"];
status?: Database["public"]["Enums"]["status"];
};
Relationships: [
{
foreignKeyName: "import_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
];
};
label: {
Row: {
boardId: number;
colourCode: string | null;
createdAt: string;
createdBy: string;
id: number;
importId: number | null;
name: string;
publicId: string;
updatedAt: string | null;
};
Insert: {
boardId: number;
colourCode?: string | null;
createdAt?: string;
createdBy: string;
id?: number;
importId?: number | null;
name: string;
publicId: string;
updatedAt?: string | null;
};
Update: {
boardId?: number;
colourCode?: string | null;
createdAt?: string;
createdBy?: string;
id?: number;
importId?: number | null;
name?: string;
publicId?: string;
updatedAt?: string | null;
};
Relationships: [
{
foreignKeyName: "label_boardId_board_id_fk";
columns: ["boardId"];
isOneToOne: false;
referencedRelation: "board";
referencedColumns: ["id"];
},
{
foreignKeyName: "label_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "label_importId_import_id_fk";
columns: ["importId"];
isOneToOne: false;
referencedRelation: "import";
referencedColumns: ["id"];
},
];
};
list: {
Row: {
boardId: number;
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
id: number;
importId: number | null;
index: number;
name: string;
publicId: string;
updatedAt: string | null;
};
Insert: {
boardId: number;
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
importId?: number | null;
index: number;
name: string;
publicId: string;
updatedAt?: string | null;
};
Update: {
boardId?: number;
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
importId?: number | null;
index?: number;
name?: string;
publicId?: string;
updatedAt?: string | null;
};
Relationships: [
{
foreignKeyName: "list_boardId_board_id_fk";
columns: ["boardId"];
isOneToOne: false;
referencedRelation: "board";
referencedColumns: ["id"];
},
{
foreignKeyName: "list_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "list_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "list_importId_import_id_fk";
columns: ["importId"];
isOneToOne: false;
referencedRelation: "import";
referencedColumns: ["id"];
},
];
};
user: {
Row: {
email: string;
emailVerified: string | null;
id: string;
image: string | null;
name: string | null;
};
Insert: {
email: string;
emailVerified?: string | null;
id: string;
image?: string | null;
name?: string | null;
};
Update: {
email?: string;
emailVerified?: string | null;
id?: string;
image?: string | null;
name?: string | null;
};
Relationships: [];
};
workspace: {
Row: {
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
id: number;
name: string;
publicId: string;
slug: string;
updatedAt: string | null;
};
Insert: {
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
name: string;
publicId: string;
slug: string;
updatedAt?: string | null;
};
Update: {
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
name?: string;
publicId?: string;
slug?: string;
updatedAt?: string | null;
};
Relationships: [
{
foreignKeyName: "workspace_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "workspace_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
];
};
workspace_members: {
Row: {
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
id: number;
publicId: string;
role: Database["public"]["Enums"]["role"];
status: Database["public"]["Enums"]["member_status"];
updatedAt: string | null;
userId: string;
workspaceId: number;
};
Insert: {
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
publicId: string;
role: Database["public"]["Enums"]["role"];
status?: Database["public"]["Enums"]["member_status"];
updatedAt?: string | null;
userId: string;
workspaceId: number;
};
Update: {
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
publicId?: string;
role?: Database["public"]["Enums"]["role"];
status?: Database["public"]["Enums"]["member_status"];
updatedAt?: string | null;
userId?: string;
workspaceId?: number;
};
Relationships: [
{
foreignKeyName: "workspace_members_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "workspace_members_userId_user_id_fk";
columns: ["userId"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "workspace_members_workspaceId_workspace_id_fk";
columns: ["workspaceId"];
isOneToOne: false;
referencedRelation: "workspace";
referencedColumns: ["id"];
},
];
};
};
Views: {
[_ in never]: never;
};
Functions: {
is_workspace_admin: {
Args: {
user_id: string;
workspace_id: number;
};
Returns: boolean;
};
push_card_index: {
Args: {
list_id: number;
card_index: number;
};
Returns: undefined;
};
reorder_cards: {
Args: {
card_id: number;
current_list_id: number;
new_list_id: number;
current_index: number;
new_index: number;
};
Returns: undefined;
};
reorder_lists: {
Args: {
board_id: number;
list_id: number;
current_index: number;
new_index: number;
};
Returns: undefined;
};
shift_card_index: {
Args: {
list_id: number;
card_index: number;
};
Returns: undefined;
};
shift_list_index: {
Args: {
board_id: number;
list_index: number;
};
Returns: undefined;
};
};
Enums: {
card_activity_type:
| "card.created"
| "card.updated.title"
| "card.updated.description"
| "card.updated.index"
| "card.updated.list"
| "card.updated.label.added"
| "card.updated.label.removed"
| "card.updated.member.added"
| "card.updated.member.removed"
| "card.archived"
| "card.updated.comment.added"
| "card.updated.comment.updated"
| "card.updated.comment.deleted";
member_status: "invited" | "active" | "removed";
role: "admin" | "member" | "guest";
source: "trello";
status: "started" | "success" | "failed";
workspace_invite_status: "pending" | "accepted" | "cancelled";
};
CompositeTypes: {
[_ in never]: never;
};
};
};
type PublicSchema = Database[Extract<keyof Database, "public">];
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R;
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R;
}
? R
: never
: never;
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I;
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I;
}
? I
: never
: never;
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U;
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U;
}
? U
: never
: never;
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never;
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never;

View File

@@ -0,0 +1,8 @@
{
"extends": "@kan/tsconfig/internal-package.json",
"include": ["src"],
"exclude": ["node_modules"],
"compilerOptions": {
"rootDir": "./src"
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": ["next", "prettier"],
"plugins": ["simple-import-sort", "unused-imports"],
"rules": {
"react/no-unescaped-entities": 0,
"react-hooks/rules-of-hooks": 0,
"no-unused-vars": "off",
"simple-import-sort/imports": [
"error",
{
// The default grouping, but with no blank lines.
"groups": [["^\\u0000", "^@?\\w", "^", "^\\."]]
}
],
"simple-import-sort/exports": "error"
}
}

View File

@@ -0,0 +1,3 @@
dist
.next
node_modules

View File

@@ -0,0 +1,8 @@
module.exports = {
quoteProps: 'consistent',
singleQuote: true,
trailingComma: 'all',
printWidth: 80,
useTabs: false,
bracketSpacing: true,
};

View File

@@ -0,0 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@@ -0,0 +1,2 @@
import Mail from '../../emails/magic-link.tsx';
export default Mail;

View File

@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
appDir: true,
externalDir: true // compile files that are located next to the .react-email directory
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1 @@
{"name":"react-email-client","version":"0.0.14","description":"The React Email preview application","license":"MIT","scripts":{"dev":"next dev","build":"next build","start":"next start","lint":"next lint","format:check":"prettier --check \"**/*.{ts,tsx,md}\"","format":"prettier --write \"**/*.{ts,tsx,md}\""},"engines":{"node":">=16.0.0"},"dependencies":{"@radix-ui/colors":"0.1.8","@radix-ui/react-collapsible":"1.0.1","@radix-ui/react-popover":"1.0.2","@radix-ui/react-slot":"1.0.1","@radix-ui/react-toggle-group":"1.0.1","@radix-ui/react-tooltip":"1.0.2","@react-email/render":"0.0.7","classnames":"2.3.2","framer-motion":"8.4.6","next":"13.2.4","prism-react-renderer":"1.3.5","react":"18.2.0","react-dom":"18.2.0","@react-email/components":"0.0.12","react-email":"^1.10.0"},"devDependencies":{"@types/classnames":"2.3.1","@types/node":"18.11.9","@types/react":"18.0.25","@types/react-dom":"18.0.9","autoprefixer":"10.4.13","eslint":"8.36.0","eslint-config-next":"13.2.4","eslint-config-prettier":"8.7.0","eslint-plugin-simple-import-sort":"10.0.0","eslint-plugin-unused-imports":"2.0.0","postcss":"8.4.19","prettier":"2.8.4","tailwindcss":"3.2.4","typescript":"4.9.3"},"readme":"ERROR: No README data found!","_id":"react-email-client@0.0.14"}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,25 @@
'use client';
import Link from 'next/link';
import { Button, Heading, Text } from '../components';
import { Shell } from '../components/shell';
export default function Home({ navItems }) {
return (
<Shell navItems={navItems}>
<div className="max-w-md border border-slate-6 mx-auto mt-56 rounded-md p-8">
<Heading as="h2" weight="medium">
Welcome to the React Email preview!
</Heading>
<Text as="p" className="mt-2 mb-4">
To start developing your next email template, you can create a{' '}
<code>.jsx</code> or <code>.tsx</code> file under the "emails" folder.
</Text>
<Button asChild>
<Link href="https://react.email/docs">Check the docs</Link>
</Button>
</div>
</Shell>
);
}

View File

@@ -0,0 +1,24 @@
import '../styles/globals.css';
import classnames from 'classnames';
import { Inter } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-black text-slate-12 font-sans">
<div className={classnames(inter.variable, 'font-sans')}>
{children}
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,11 @@
import { getEmails } from '../utils/get-emails';
import Home from './home';
export default async function Index() {
const { emails } = await getEmails();
return <Home navItems={emails} />;
}
export const metadata = {
title: 'React Email',
};

View File

@@ -0,0 +1,56 @@
import { render } from '@react-email/render';
import { promises as fs } from 'fs';
import { dirname, join as pathJoin } from 'path';
import { CONTENT_DIR, getEmails } from '../../../utils/get-emails';
import Preview from './preview';
export const dynamicParams = true;
export async function generateStaticParams() {
const { emails } = await getEmails();
const paths = emails.map((email) => {
return { slug: email };
});
return paths;
}
export default async function Page({ params }) {
const { emails, filenames } = await getEmails();
const template = filenames.filter((email) => {
const [fileName] = email.split('.');
return params.slug === fileName;
});
const Email = (await import(`../../../../emails/${params.slug}`)).default;
const markup = render(<Email />, { pretty: true });
const plainText = render(<Email />, { plainText: true });
const basePath = pathJoin(process.cwd(), CONTENT_DIR);
const path = pathJoin(basePath, template[0]);
// the file is actually just re-exporting the default export of the original file. We need to resolve this first
const exportTemplateFile: string = await fs.readFile(path, {
encoding: 'utf-8',
});
const importPath = exportTemplateFile.match(/import Mail from '(.+)';/)![1];
const originalFilePath = pathJoin(dirname(path), importPath);
const reactMarkup: string = await fs.readFile(originalFilePath, {
encoding: 'utf-8',
});
return (
<Preview
navItems={emails}
slug={params.slug}
markup={markup}
reactMarkup={reactMarkup}
plainText={plainText}
/>
);
}
export async function generateMetadata({ params }) {
return { title: `${params.slug} — React Email` };
}

View File

@@ -0,0 +1,72 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
import { CodeContainer } from '../../../components/code-container';
import { Shell } from '../../../components/shell';
import { Tooltip } from '../../../components/tooltip';
export default function Preview({
navItems,
slug,
markup,
reactMarkup,
plainText,
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeView, setActiveView] = React.useState('desktop');
const [activeLang, setActiveLang] = React.useState('jsx');
React.useEffect(() => {
const view = searchParams.get('view');
const lang = searchParams.get('lang');
if (view === 'source' || view === 'desktop') {
setActiveView(view);
}
if (lang === 'jsx' || lang === 'markup' || lang === 'markdown') {
setActiveLang(lang);
}
}, [searchParams]);
const handleViewChange = (view: string) => {
setActiveView(view);
router.push(`${pathname}?view=${view}`);
};
const handleLangChange = (lang: string) => {
setActiveLang(lang);
router.push(`${pathname}?view=source&lang=${lang}`);
};
return (
<Shell
navItems={navItems}
title={slug}
markup={markup}
activeView={activeView}
setActiveView={handleViewChange}
>
{activeView === 'desktop' ? (
<iframe srcDoc={markup} className="w-full h-[calc(100vh_-_70px)]" />
) : (
<div className="flex gap-6 mx-auto p-6 max-w-3xl">
<Tooltip.Provider>
<CodeContainer
markups={[
{ language: 'jsx', content: reactMarkup },
{ language: 'markup', content: markup },
{ language: 'markdown', content: plainText },
]}
activeLang={activeLang}
setActiveLang={handleLangChange}
/>
</Tooltip.Provider>
</div>
)}
</Shell>
);
}

View File

@@ -0,0 +1,85 @@
import * as SlotPrimitive from '@radix-ui/react-slot';
import classnames from 'classnames';
import * as React from 'react';
import { unreachable } from '../utils';
type ButtonElement = React.ElementRef<'button'>;
type RootProps = React.ComponentPropsWithoutRef<'button'>;
type Appearance = 'white' | 'gradient';
type Size = '1' | '2' | '3' | '4';
interface ButtonProps extends RootProps {
asChild?: boolean;
appearance?: Appearance;
size?: Size;
}
export const Button = React.forwardRef<ButtonElement, Readonly<ButtonProps>>(
(
{
asChild,
appearance = 'white',
className,
children,
size = '2',
...props
},
forwardedRef,
) => {
const classNames = classnames(
getSize(size),
getAppearance(appearance),
'inline-flex items-center justify-center border font-medium',
className,
);
return asChild ? (
<SlotPrimitive.Slot ref={forwardedRef} {...props} className={classNames}>
<SlotPrimitive.Slottable>{children}</SlotPrimitive.Slottable>
</SlotPrimitive.Slot>
) : (
<button ref={forwardedRef} className={classNames} {...props}>
{children}
</button>
);
},
);
Button.displayName = 'Button';
const getAppearance = (appearance: Appearance | undefined) => {
switch (appearance) {
case undefined:
case 'white':
return [
'bg-white text-black',
'hover:bg-white/90',
'focus:ring-2 focus:ring-white/20 focus:outline-none focus:bg-white/90',
];
case 'gradient':
return [
'bg-gradient backdrop-blur-[20px] border-[#34343A]',
'hover:bg-gradientHover',
'focus:ring-2 focus:ring-white/20 focus:outline-none focus:bg-gradientHover',
];
default:
unreachable(appearance);
}
};
const getSize = (size: Size | undefined) => {
switch (size) {
case '1':
return '';
case undefined:
case '2':
return 'text-[14px] h-8 px-3 rounded-md gap-2';
case '3':
return 'text-[14px] h-10 px-4 rounded-md gap-2';
case '4':
return 'text-base h-11 px-4 rounded-md gap-2';
default:
unreachable(size);
}
};

View File

@@ -0,0 +1,133 @@
import { LayoutGroup, motion } from 'framer-motion';
import { Language } from 'prism-react-renderer';
import * as React from 'react';
import { copyTextToClipboard } from '../utils';
import languageMap from '../utils/language-map';
import { Code } from './code';
import { IconButton } from './icon-button';
import { IconCheck } from './icon-check';
import { IconClipboard } from './icon-clipboard';
import { IconDownload } from './icon-download';
import { Tooltip } from './tooltip';
interface CodeContainerProps {
markups: MarkupProps[];
activeLang: string;
setActiveLang: (lang: string) => void;
}
interface MarkupProps {
language: Language;
content: string;
}
export const CodeContainer: React.FC<Readonly<CodeContainerProps>> = ({
markups,
activeLang,
setActiveLang,
}) => {
const [isCopied, setIsCopied] = React.useState(false);
const renderDownloadIcon = () => {
let value = markups.filter((markup) => markup.language === activeLang);
const file = new File([value[0].content], `email.${value[0].language}`);
const url = URL.createObjectURL(file);
return (
<a
href={url}
download={file.name}
className="text-slate-11 transition ease-in-out duration-200 hover:text-slate-12"
>
<IconDownload />
</a>
);
};
const renderClipboardIcon = () => {
const handleClipboard = async () => {
const activeContent = markups.filter(({ language }) => {
return activeLang === language;
});
setIsCopied(true);
await copyTextToClipboard(activeContent[0].content);
setTimeout(() => setIsCopied(false), 3000);
};
return (
<IconButton onClick={handleClipboard}>
{isCopied ? <IconCheck /> : <IconClipboard />}
</IconButton>
);
};
React.useEffect(() => {
setIsCopied(false);
}, [activeLang]);
return (
<pre
className={
'border-slate-6 relative w-full items-center whitespace-pre rounded-md border text-sm backdrop-blur-md'
}
style={{
lineHeight: '130%',
background:
'linear-gradient(145.37deg, rgba(255, 255, 255, 0.09) -8.75%, rgba(255, 255, 255, 0.027) 83.95%)',
boxShadow: 'rgb(0 0 0 / 10%) 0px 5px 30px -5px',
}}
>
<div className="h-9 border-b border-slate-6">
<div className="flex">
<LayoutGroup id="code">
{markups.map(({ language }) => {
const isCurrentLang = activeLang === language;
return (
<motion.button
className={`relative py-[8px] px-4 text-sm font-medium font-sans transition ease-in-out duration-200 hover:text-slate-12 ${
activeLang !== language ? 'text-slate-11' : 'text-slate-12'
}`}
onClick={() => setActiveLang(language)}
key={language}
>
{isCurrentLang && (
<motion.span
layoutId="code"
className="absolute left-0 right-0 top-0 bottom-0 bg-slate-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
{languageMap[language]}
</motion.button>
);
})}
</LayoutGroup>
</div>
<Tooltip>
<Tooltip.Trigger className="absolute top-2 right-2 hidden md:block">
{renderClipboardIcon()}
</Tooltip.Trigger>
<Tooltip.Content>Copy to Clipboard</Tooltip.Content>
</Tooltip>
<Tooltip>
<Tooltip.Trigger className="text-gray-11 absolute top-2 right-8 hidden md:block">
{renderDownloadIcon()}
</Tooltip.Trigger>
<Tooltip.Content>Download</Tooltip.Content>
</Tooltip>
</div>
{markups.map(({ language, content }) => {
return (
<div
className={`${activeLang !== language && 'hidden'}`}
key={language}
>
<Code language={language}>{content}</Code>
</div>
);
})}
</pre>
);
};

View File

@@ -0,0 +1,112 @@
import classnames from 'classnames';
import Highlight, { defaultProps, Language } from 'prism-react-renderer';
import * as React from 'react';
interface CodeProps {
children: any;
className?: string;
language?: Language;
}
const theme = {
plain: {
color: '#EDEDEF',
fontSize: 13,
fontFamily: 'MonoLisa, Menlo, monospace',
},
styles: [
{
types: ['comment'],
style: {
color: '#706F78',
},
},
{
types: ['atrule', 'keyword', 'attr-name', 'selector'],
style: {
color: '#7E7D86',
},
},
{
types: ['punctuation', 'operator'],
style: {
color: '#706F78',
},
},
{
types: ['class-name', 'function', 'tag', 'key-white'],
style: {
color: '#EDEDEF',
},
},
],
};
export const Code: React.FC<Readonly<CodeProps>> = ({
children,
language = 'html',
}) => {
const [isCopied, setIsCopied] = React.useState(false);
const value = children.trim();
const file = new File([value], `email.${language}`);
const url = URL.createObjectURL(file);
return (
<Highlight
{...defaultProps}
theme={theme}
code={value}
language={language as Language}
>
{({ tokens, getLineProps, getTokenProps }) => (
<>
<div
className="absolute right-0 top-0 h-px w-[200px]"
style={{
background:
'linear-gradient(90deg, rgba(56, 189, 248, 0) 0%, rgba(56, 189, 248, 0) 0%, rgba(232, 232, 232, 0.2) 33.02%, rgba(143, 143, 143, 0.6719) 64.41%, rgba(236, 72, 153, 0) 98.93%)',
}}
/>
<div className="p-4 h-[650px] overflow-auto">
{tokens.map((line, i) => {
return (
<div
key={i}
{...getLineProps({ line, key: i })}
className={classnames('whitespace-pre', {
"before:text-slate-11 before:mr-2 before:content-['$']":
language === 'bash' && tokens && tokens.length === 1,
})}
>
{line.map((token, key) => {
const isException =
token.content === 'from' &&
line[key + 1]?.content === ':';
const newTypes = isException
? [...token.types, 'key-white']
: token.types;
token.types = newTypes;
return (
<React.Fragment key={key}>
<span {...getTokenProps({ token, key })} />
</React.Fragment>
);
})}
</div>
);
})}
</div>
<div
className="absolute left-0 bottom-0 h-px w-[200px]"
style={{
background:
'linear-gradient(90deg, rgba(56, 189, 248, 0) 0%, rgba(56, 189, 248, 0) 0%, rgba(232, 232, 232, 0.2) 33.02%, rgba(143, 143, 143, 0.6719) 64.41%, rgba(236, 72, 153, 0) 98.93%)',
}}
/>
</>
)}
</Highlight>
);
};

View File

@@ -0,0 +1,114 @@
import * as SlotPrimitive from '@radix-ui/react-slot';
import classnames from 'classnames';
import * as React from 'react';
import { As, unreachable } from '../utils';
export type HeadingSize =
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| '10';
export type HeadingColor = 'white' | 'gray';
export type HeadingWeight = 'medium' | 'bold';
interface HeadingOwnProps {
size?: HeadingSize;
color?: HeadingColor;
weight?: HeadingWeight;
}
type HeadingProps = As<'h1', 'h2', 'h3', 'h4', 'h5', 'h6'> & HeadingOwnProps;
export const Heading = React.forwardRef<
HTMLHeadingElement,
Readonly<HeadingProps>
>(
(
{
as: Tag = 'h1',
size = '3',
className,
color = 'white',
children,
weight = 'bold',
...props
},
forwardedRef,
) => (
<SlotPrimitive.Slot
ref={forwardedRef}
className={classnames(
className,
getSizesClassNames(size),
getColorClassNames(color),
getWeightClassNames(weight),
)}
{...props}
>
<Tag>{children}</Tag>
</SlotPrimitive.Slot>
),
);
const getSizesClassNames = (size: HeadingSize | undefined) => {
switch (size) {
case '1':
return 'text-xs';
case '2':
return 'text-sm';
case undefined:
case '3':
return 'text-base';
case '4':
return 'text-lg';
case '5':
return 'text-xl tracking-[-0.16px]';
case '6':
return 'text-2xl tracking-[-0.288px]';
case '7':
return 'text-[28px] leading-[34px] tracking-[-0.416px]';
case '8':
return 'text-[35px] leading-[42px] tracking-[-0.64px]';
case '9':
return 'text-6xl leading-[73px] tracking-[-0.896px]';
case '10':
return [
'text-[38px] leading-[46px]',
'md:text-[70px] md:leading-[85px] tracking-[-1.024px;]',
];
default:
return unreachable(size);
}
};
const getColorClassNames = (color: HeadingColor | undefined) => {
switch (color) {
case 'gray':
return 'text-slate-11';
case 'white':
case undefined:
return 'text-slate-12';
default:
return unreachable(color);
}
};
const getWeightClassNames = (weight: HeadingWeight | undefined) => {
switch (weight) {
case 'medium':
return 'font-medium';
case 'bold':
case undefined:
return 'font-bold';
default:
return unreachable(weight);
}
};
Heading.displayName = 'Heading';

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
export interface IconProps {
size?: number;
}
export type IconElement = React.ElementRef<'svg'>;
export type RootProps = React.ComponentPropsWithoutRef<'svg'>;
export interface IconProps extends RootProps {}
export const IconBase = React.forwardRef<IconElement, Readonly<IconProps>>(
({ size = 20, ...props }, forwardedRef) => (
<svg
ref={forwardedRef}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
/>
),
);
IconBase.displayName = 'IconBase';

View File

@@ -0,0 +1,23 @@
import classnames from 'classnames';
import * as React from 'react';
export interface IconButtonProps
extends React.ComponentPropsWithoutRef<'button'> {}
export const IconButton = React.forwardRef<
HTMLButtonElement,
Readonly<IconButtonProps>
>(({ children, className, ...props }, forwardedRef) => (
<button
{...props}
ref={forwardedRef}
className={classnames(
'rounded text-slate-11 focus:text-slate-12 ease-in-out transition duration-200 focus:outline-none focus:ring-2 focus:ring-gray-8 hover:text-slate-12',
className,
)}
>
{children}
</button>
));
IconButton.displayName = 'IconButton';

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import { IconBase, IconElement, IconProps } from './icon-base';
export const IconCheck = React.forwardRef<IconElement, Readonly<IconProps>>(
({ ...props }, forwardedRef) => (
<IconBase ref={forwardedRef} {...props}>
<path
d="M16.25 8.75L10.406 15.25L7.75 12.75"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</IconBase>
),
);
IconCheck.displayName = 'IconCheck';

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { IconBase, IconElement, IconProps } from './icon-base';
export const IconClipboard = React.forwardRef<IconElement, Readonly<IconProps>>(
({ ...props }, forwardedRef) => (
<IconBase ref={forwardedRef} {...props}>
<path
d="M9 6.75H7.75C6.64543 6.75 5.75 7.64543 5.75 8.75V17.25C5.75 18.3546 6.64543 19.25 7.75 19.25H16.25C17.3546 19.25 18.25 18.3546 18.25 17.25V8.75C18.25 7.64543 17.3546 6.75 16.25 6.75H15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 8.25H10C9.44772 8.25 9 7.80228 9 7.25V5.75C9 5.19772 9.44772 4.75 10 4.75H14C14.5523 4.75 15 5.19772 15 5.75V7.25C15 7.80228 14.5523 8.25 14 8.25Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.75 12.25H14.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.75 15.25H14.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</IconBase>
),
);
IconClipboard.displayName = 'IconClipboard';

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import { IconBase, IconElement, IconProps } from './icon-base';
export const IconDownload = React.forwardRef<IconElement, Readonly<IconProps>>(
({ ...props }, forwardedRef) => (
<IconBase ref={forwardedRef} {...props}>
<path
d="M4.75 14.75v1.5a3 3 0 0 0 3 3h8.5a3 3 0 0 0 3-3v-1.5M12 14.25v-9.5M8.75 10.75l3.25 3.5 3.25-3.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</IconBase>
),
);
IconDownload.displayName = 'IconDownload';

View File

@@ -0,0 +1,7 @@
export * from './button';
export * from './code';
export * from './heading';
export * from './logo';
export * from './sidebar';
export * from './text';
export * from './topbar';

View File

@@ -0,0 +1,71 @@
import * as React from 'react';
type LogoElement = React.ElementRef<'svg'>;
type RootProps = React.ComponentPropsWithoutRef<'svg'>;
export const Logo = React.forwardRef<LogoElement, Readonly<RootProps>>(
({ ...props }, forwardedRef) => (
<svg
width="119"
height="32"
viewBox="0 0 119 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_27_291)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24.4558 24.4853C25.2339 23.7073 25.3805 22.6549 25.2947 21.746C25.2078 20.8254 24.8697 19.8258 24.3896 18.8287C23.957 17.9302 23.3802 16.9745 22.6821 16C23.3802 15.0255 23.957 14.0698 24.3896 13.1713C24.8697 12.1742 25.2078 11.1746 25.2947 10.254C25.3805 9.34508 25.2339 8.29273 24.4558 7.51472C23.6778 6.73671 22.6255 6.59004 21.7165 6.67584C20.796 6.76273 19.7964 7.10086 18.7993 7.58094C17.9007 8.01357 16.945 8.59036 15.9706 9.28842C14.9961 8.59036 14.0404 8.01357 13.1418 7.58094C12.1447 7.10086 11.1451 6.76273 10.2246 6.67584C9.31564 6.59004 8.26329 6.73671 7.48528 7.51472C6.70727 8.29273 6.5606 9.34508 6.6464 10.254C6.7333 11.1746 7.07142 12.1742 7.5515 13.1713C7.98414 14.0698 8.56092 15.0255 9.25898 16C8.56092 16.9745 7.98414 17.9302 7.5515 18.8287C7.07142 19.8258 6.7333 20.8254 6.6464 21.746C6.5606 22.6549 6.70727 23.7073 7.48528 24.4853C8.26329 25.2633 9.31564 25.41 10.2246 25.3242C11.1451 25.2373 12.1447 24.8991 13.1418 24.4191C14.0404 23.9864 14.9961 23.4096 15.9706 22.7116C16.945 23.4096 17.9007 23.9864 18.7993 24.4191C19.7964 24.8991 20.796 25.2373 21.7165 25.3242C22.6255 25.41 23.6778 25.2633 24.4558 24.4853ZM15.9706 20.948C16.8399 20.2684 17.724 19.4874 18.591 18.6205C19.458 17.7535 20.239 16.8693 20.9186 16C20.239 15.1307 19.458 14.2465 18.591 13.3795C17.724 12.5126 16.8399 11.7316 15.9706 11.052C15.1012 11.7316 14.2171 12.5126 13.3501 13.3795C12.4831 14.2465 11.7021 15.1307 11.0225 16C11.7021 16.8693 12.4831 17.7535 13.3501 18.6205C14.2171 19.4874 15.1012 20.2684 15.9706 20.948ZM17.1498 21.8145C17.968 21.1558 18.7885 20.4195 19.5893 19.6187C20.39 18.818 21.1264 17.9974 21.7851 17.1792C23.7187 19.9919 24.4627 22.4819 23.4576 23.487C22.4524 24.4922 19.9625 23.7482 17.1498 21.8145ZM10.156 17.1792C10.8148 17.9974 11.5511 18.818 12.3518 19.6187C13.1526 20.4195 13.9731 21.1558 14.7914 21.8145C11.9786 23.7482 9.48871 24.4922 8.48355 23.487C7.47839 22.4819 8.22238 19.9919 10.156 17.1792ZM10.156 14.8208C10.8148 14.0026 11.5511 13.182 12.3518 12.3813C13.1526 11.5805 13.9731 10.8442 14.7914 10.1855C11.9786 8.25182 9.48871 7.50783 8.48355 8.51299C7.47839 9.51815 8.22238 12.0081 10.156 14.8208ZM17.1498 10.1855C17.968 10.8442 18.7885 11.5805 19.5893 12.3813C20.39 13.182 21.1264 14.0026 21.7851 14.8208C23.7187 12.0081 24.4627 9.51815 23.4576 8.51299C22.4524 7.50783 19.9625 8.25182 17.1498 10.1855Z"
fill="white"
stroke="white"
strokeWidth="0.5"
/>
</g>
<path
d="M36 22.176V13.744H37.936L37.968 16.432L37.696 15.824C37.8133 15.3973 38.016 15.0133 38.304 14.672C38.592 14.3307 38.9227 14.064 39.296 13.872C39.68 13.6693 40.08 13.568 40.496 13.568C40.6773 13.568 40.848 13.584 41.008 13.616C41.1787 13.648 41.3173 13.6853 41.424 13.728L40.896 15.888C40.7787 15.824 40.6347 15.7707 40.464 15.728C40.2933 15.6853 40.1227 15.664 39.952 15.664C39.6853 15.664 39.4293 15.7173 39.184 15.824C38.9493 15.92 38.7413 16.0587 38.56 16.24C38.3787 16.4213 38.2347 16.6347 38.128 16.88C38.032 17.1147 37.984 17.3813 37.984 17.68V22.176H36Z"
fill="white"
/>
<path
d="M45.907 22.336C45.0217 22.336 44.2377 22.1493 43.555 21.776C42.883 21.4027 42.355 20.896 41.971 20.256C41.5977 19.6053 41.411 18.864 41.411 18.032C41.411 17.3707 41.5177 16.768 41.731 16.224C41.9443 15.68 42.2377 15.2107 42.611 14.816C42.995 14.4107 43.4483 14.1013 43.971 13.888C44.5043 13.664 45.0857 13.552 45.715 13.552C46.2697 13.552 46.787 13.6587 47.267 13.872C47.747 14.0853 48.163 14.3787 48.515 14.752C48.867 15.1147 49.1337 15.552 49.315 16.064C49.507 16.5653 49.5977 17.1147 49.587 17.712L49.571 18.4H42.739L42.371 17.056H47.923L47.667 17.328V16.976C47.635 16.6453 47.5283 16.3573 47.347 16.112C47.1657 15.856 46.931 15.6587 46.643 15.52C46.3657 15.3707 46.0563 15.296 45.715 15.296C45.1923 15.296 44.7497 15.3973 44.387 15.6C44.035 15.8027 43.7683 16.096 43.587 16.48C43.4057 16.8533 43.315 17.3227 43.315 17.888C43.315 18.432 43.427 18.9067 43.651 19.312C43.8857 19.7173 44.211 20.032 44.627 20.256C45.0537 20.4693 45.5497 20.576 46.115 20.576C46.5097 20.576 46.8723 20.512 47.203 20.384C47.5337 20.256 47.891 20.0267 48.275 19.696L49.251 21.056C48.963 21.3227 48.6323 21.552 48.259 21.744C47.8963 21.9253 47.5123 22.0693 47.107 22.176C46.7017 22.2827 46.3017 22.336 45.907 22.336Z"
fill="white"
/>
<path
d="M54.094 22.336C53.4007 22.336 52.7713 22.144 52.206 21.76C51.6407 21.376 51.1873 20.8533 50.846 20.192C50.5047 19.5307 50.334 18.7787 50.334 17.936C50.334 17.0933 50.5047 16.3413 50.846 15.68C51.1873 15.0187 51.6513 14.5013 52.238 14.128C52.8247 13.7547 53.486 13.568 54.222 13.568C54.6487 13.568 55.038 13.632 55.39 13.76C55.742 13.8773 56.0513 14.048 56.318 14.272C56.5847 14.496 56.8033 14.752 56.974 15.04C57.1553 15.328 57.278 15.6373 57.342 15.968L56.91 15.856V13.744H58.894V22.176H56.894V20.16L57.358 20.08C57.2833 20.368 57.1447 20.6507 56.942 20.928C56.75 21.1947 56.5047 21.4347 56.206 21.648C55.918 21.8507 55.5927 22.016 55.23 22.144C54.878 22.272 54.4993 22.336 54.094 22.336ZM54.638 20.592C55.0967 20.592 55.502 20.48 55.854 20.256C56.206 20.032 56.478 19.7227 56.67 19.328C56.8727 18.9227 56.974 18.4587 56.974 17.936C56.974 17.424 56.8727 16.9707 56.67 16.576C56.478 16.1813 56.206 15.872 55.854 15.648C55.502 15.424 55.0967 15.312 54.638 15.312C54.1793 15.312 53.774 15.424 53.422 15.648C53.0807 15.872 52.814 16.1813 52.622 16.576C52.43 16.9707 52.334 17.424 52.334 17.936C52.334 18.4587 52.43 18.9227 52.622 19.328C52.814 19.7227 53.0807 20.032 53.422 20.256C53.774 20.48 54.1793 20.592 54.638 20.592Z"
fill="white"
/>
<path
d="M64.3716 22.336C63.5823 22.336 62.873 22.144 62.2436 21.76C61.6143 21.376 61.1183 20.8533 60.7556 20.192C60.393 19.5307 60.2116 18.784 60.2116 17.952C60.2116 17.12 60.393 16.3733 60.7556 15.712C61.1183 15.0507 61.6143 14.528 62.2436 14.144C62.873 13.76 63.5823 13.568 64.3716 13.568C65.129 13.568 65.817 13.712 66.4356 14C67.0543 14.288 67.5343 14.688 67.8756 15.2L66.7876 16.512C66.6276 16.288 66.425 16.0853 66.1796 15.904C65.9343 15.7227 65.673 15.5787 65.3956 15.472C65.1183 15.3653 64.841 15.312 64.5636 15.312C64.0943 15.312 63.673 15.4293 63.2996 15.664C62.937 15.888 62.649 16.2027 62.4356 16.608C62.2223 17.0027 62.1156 17.4507 62.1156 17.952C62.1156 18.4533 62.2223 18.9013 62.4356 19.296C62.6596 19.6907 62.9583 20.0053 63.3316 20.24C63.705 20.4747 64.121 20.592 64.5796 20.592C64.857 20.592 65.1236 20.5493 65.3796 20.464C65.6463 20.368 65.897 20.2347 66.1316 20.064C66.3663 19.8933 66.585 19.68 66.7876 19.424L67.8756 20.752C67.513 21.2213 67.0116 21.6053 66.3716 21.904C65.7423 22.192 65.0756 22.336 64.3716 22.336Z"
fill="white"
/>
<path
d="M69.8726 22.176V11.6H71.8406V22.176H69.8726ZM68.2086 15.568V13.744H73.6806V15.568H68.2086Z"
fill="white"
/>
<path
d="M82.9945 22.336C82.1092 22.336 81.3252 22.1493 80.6425 21.776C79.9705 21.4027 79.4425 20.896 79.0585 20.256C78.6852 19.6053 78.4985 18.864 78.4985 18.032C78.4985 17.3707 78.6052 16.768 78.8185 16.224C79.0318 15.68 79.3252 15.2107 79.6985 14.816C80.0825 14.4107 80.5358 14.1013 81.0585 13.888C81.5918 13.664 82.1732 13.552 82.8025 13.552C83.3572 13.552 83.8745 13.6587 84.3545 13.872C84.8345 14.0853 85.2505 14.3787 85.6025 14.752C85.9545 15.1147 86.2212 15.552 86.4025 16.064C86.5945 16.5653 86.6852 17.1147 86.6745 17.712L86.6585 18.4H79.8265L79.4585 17.056H85.0105L84.7545 17.328V16.976C84.7225 16.6453 84.6158 16.3573 84.4345 16.112C84.2532 15.856 84.0185 15.6587 83.7305 15.52C83.4532 15.3707 83.1438 15.296 82.8025 15.296C82.2798 15.296 81.8372 15.3973 81.4745 15.6C81.1225 15.8027 80.8558 16.096 80.6745 16.48C80.4932 16.8533 80.4025 17.3227 80.4025 17.888C80.4025 18.432 80.5145 18.9067 80.7385 19.312C80.9732 19.7173 81.2985 20.032 81.7145 20.256C82.1412 20.4693 82.6372 20.576 83.2025 20.576C83.5972 20.576 83.9598 20.512 84.2905 20.384C84.6212 20.256 84.9785 20.0267 85.3625 19.696L86.3385 21.056C86.0505 21.3227 85.7198 21.552 85.3465 21.744C84.9838 21.9253 84.5998 22.0693 84.1945 22.176C83.7892 22.2827 83.3892 22.336 82.9945 22.336Z"
fill="white"
/>
<path
d="M87.9655 22.176V13.744H89.9015L89.9335 15.44L89.6135 15.568C89.7095 15.2907 89.8535 15.0347 90.0455 14.8C90.2375 14.5547 90.4668 14.3467 90.7335 14.176C91.0002 13.9947 91.2828 13.856 91.5815 13.76C91.8802 13.6533 92.1842 13.6 92.4935 13.6C92.9522 13.6 93.3575 13.6747 93.7095 13.824C94.0722 13.9627 94.3708 14.1867 94.6055 14.496C94.8508 14.8053 95.0322 15.2 95.1495 15.68L94.8455 15.616L94.9735 15.36C95.0908 15.104 95.2562 14.8747 95.4695 14.672C95.6828 14.4587 95.9228 14.272 96.1895 14.112C96.4562 13.9413 96.7335 13.8133 97.0215 13.728C97.3202 13.6427 97.6135 13.6 97.9015 13.6C98.5415 13.6 99.0748 13.728 99.5015 13.984C99.9282 14.24 100.248 14.6293 100.462 15.152C100.675 15.6747 100.782 16.32 100.782 17.088V22.176H98.7975V17.216C98.7975 16.7893 98.7388 16.4373 98.6215 16.16C98.5148 15.8827 98.3442 15.68 98.1095 15.552C97.8855 15.4133 97.6028 15.344 97.2615 15.344C96.9948 15.344 96.7388 15.392 96.4935 15.488C96.2588 15.5733 96.0562 15.7013 95.8855 15.872C95.7148 16.032 95.5815 16.2187 95.4855 16.432C95.3895 16.6453 95.3415 16.88 95.3415 17.136V22.176H93.3575V17.2C93.3575 16.7947 93.2988 16.4587 93.1815 16.192C93.0642 15.9147 92.8935 15.7067 92.6695 15.568C92.4455 15.4187 92.1735 15.344 91.8535 15.344C91.5868 15.344 91.3362 15.392 91.1015 15.488C90.8668 15.5733 90.6642 15.696 90.4935 15.856C90.3228 16.016 90.1895 16.2027 90.0935 16.416C89.9975 16.6293 89.9495 16.864 89.9495 17.12V22.176H87.9655Z"
fill="white"
/>
<path
d="M105.73 22.336C105.037 22.336 104.408 22.144 103.842 21.76C103.277 21.376 102.824 20.8533 102.482 20.192C102.141 19.5307 101.97 18.7787 101.97 17.936C101.97 17.0933 102.141 16.3413 102.482 15.68C102.824 15.0187 103.288 14.5013 103.874 14.128C104.461 13.7547 105.122 13.568 105.858 13.568C106.285 13.568 106.674 13.632 107.026 13.76C107.378 13.8773 107.688 14.048 107.954 14.272C108.221 14.496 108.44 14.752 108.61 15.04C108.792 15.328 108.914 15.6373 108.978 15.968L108.546 15.856V13.744H110.53V22.176H108.53V20.16L108.994 20.08C108.92 20.368 108.781 20.6507 108.578 20.928C108.386 21.1947 108.141 21.4347 107.842 21.648C107.554 21.8507 107.229 22.016 106.866 22.144C106.514 22.272 106.136 22.336 105.73 22.336ZM106.274 20.592C106.733 20.592 107.138 20.48 107.49 20.256C107.842 20.032 108.114 19.7227 108.306 19.328C108.509 18.9227 108.61 18.4587 108.61 17.936C108.61 17.424 108.509 16.9707 108.306 16.576C108.114 16.1813 107.842 15.872 107.49 15.648C107.138 15.424 106.733 15.312 106.274 15.312C105.816 15.312 105.41 15.424 105.058 15.648C104.717 15.872 104.45 16.1813 104.258 16.576C104.066 16.9707 103.97 17.424 103.97 17.936C103.97 18.4587 104.066 18.9227 104.258 19.328C104.45 19.7227 104.717 20.032 105.058 20.256C105.41 20.48 105.816 20.592 106.274 20.592Z"
fill="white"
/>
<path
d="M112.616 22.176V13.744H114.584V22.176H112.616ZM113.576 11.952C113.181 11.952 112.872 11.856 112.648 11.664C112.435 11.4613 112.328 11.1787 112.328 10.816C112.328 10.4747 112.44 10.1973 112.664 9.984C112.888 9.77067 113.192 9.664 113.576 9.664C113.981 9.664 114.291 9.76534 114.504 9.968C114.728 10.16 114.84 10.4427 114.84 10.816C114.84 11.1467 114.728 11.4187 114.504 11.632C114.28 11.8453 113.971 11.952 113.576 11.952Z"
fill="white"
/>
<path d="M116.675 22.176V10.336H118.659V22.176H116.675Z" fill="white" />
<defs>
<clipPath id="clip0_27_291">
<rect width="32" height="32" rx="8" fill="white" />
</clipPath>
</defs>
</svg>
),
);
Logo.displayName = 'Logo';

View File

@@ -0,0 +1,118 @@
import * as Popover from '@radix-ui/react-popover';
import * as React from 'react';
import { inter } from '../app/layout';
import { Button } from './button';
import { Text } from './text';
export const Send = ({ markup }: { markup: string }) => {
const [to, setTo] = React.useState('');
const [subject, setSubject] = React.useState('Testing React Email');
const [isSending, setIsSending] = React.useState(false);
const onFormSubmit = async (e: React.FormEvent) => {
try {
e.preventDefault();
setIsSending(true);
const response = await fetch('https://react.email/api/send/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to,
subject,
html: markup,
}),
});
if (response.status === 429) {
const { error } = await response.json();
alert(error);
}
} catch (e) {
alert('Something went wrong. Please try again.');
} finally {
setIsSending(false);
}
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button className="box-border outline-none self-center w-20 h-5 flex items-center justify-center rounded-lg text-center transition duration-300 ease-in-out border border-slate-6 text-slate-11 text-sm px-4 py-4 hover:border-slate-12 hover:text-slate-12 font-sans">
Send
</button>
</Popover.Trigger>
<Popover.Anchor />
<Popover.Portal>
<Popover.Content
align="end"
className={`w-80 -mt-10 p-3 bg-black border border-slate-6 text-slate-11 rounded-lg font-sans ${inter.variable}`}
>
<Popover.Close
aria-label="Close"
className="absolute right-2 flex items-center justify-center w-6 h-6 text-xs text-slate-11 hover:text-slate-12 transition duration-300 ease-in-out rounded-full"
>
</Popover.Close>
<form onSubmit={onFormSubmit} className="mt-1">
<label
htmlFor="to"
className="text-slate-10 text-xs uppercase mb-2 block"
>
Recipient
</label>
<input
autoFocus={true}
className="appearance-none rounded-lg px-2 py-1 mb-3 outline-none w-full bg-slate-3 border placeholder-slate-8 border-slate-6 text-slate-12 text-sm focus:ring-1 focus:ring-slate-12 transition duration-300 ease-in-out"
onChange={(e) => setTo(e.target.value)}
defaultValue={to}
placeholder="you@example.com"
type="email"
id="to"
required
/>
<label
htmlFor="subject"
className="text-slate-10 text-xs uppercase mb-2 block"
>
Subject
</label>
<input
className="appearance-none rounded-lg px-2 py-1 mb-3 outline-none w-full bg-slate-3 border placeholder-slate-8 border-slate-6 text-slate-12 text-sm focus:ring-1 focus:ring-slate-12 transition duration-300 ease-in-out"
onChange={(e) => setSubject(e.target.value)}
defaultValue={subject}
placeholder="My Email"
type="text"
id="subject"
required
/>
<input
type="checkbox"
className="appearance-none checked:bg-blue-500"
/>
<div className="flex items-center justify-between">
<Text className="inline-block" size="1">
Powered by{' '}
<a
className="hover:text-slate-12 transition ease-in-out duration-300"
href="https://resend.com"
target="_blank"
rel="noreferrer"
>
Resend
</a>
</Text>
<Button
type="submit"
disabled={subject.length === 0 || to.length === 0 || isSending}
className="disabled:bg-slate-11 disabled:border-transparent"
>
Send
</Button>
</div>
</form>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};

View File

@@ -0,0 +1,41 @@
import * as React from 'react';
import { Sidebar } from './sidebar';
import { Topbar } from './topbar';
type ShellElement = React.ElementRef<'div'>;
type RootProps = React.ComponentPropsWithoutRef<'div'>;
interface ShellProps extends RootProps {
navItems: string[];
markup?: string;
activeView?: string;
setActiveView?: (view: string) => void;
}
export const Shell = React.forwardRef<ShellElement, Readonly<ShellProps>>(
(
{ title, navItems, children, markup, activeView, setActiveView },
forwardedRef,
) => {
return (
<div ref={forwardedRef} className="flex justify-between h-screen">
<Sidebar navItems={navItems} title={title} />
<main className="w-[calc(100%_-_275px)] bg-slate-2">
{title && (
<Topbar
title={title}
activeView={activeView}
setActiveView={setActiveView}
markup={markup}
/>
)}
<div className="relative h-[calc(100vh_-_70px)] overflow-auto">
<div className="mx-auto">{children}</div>
</div>
</main>
</div>
);
},
);
Shell.displayName = 'Shell';

View File

@@ -0,0 +1,159 @@
import * as Collapsible from '@radix-ui/react-collapsible';
import classnames from 'classnames';
import { LayoutGroup, motion } from 'framer-motion';
import Link from 'next/link';
import * as React from 'react';
import { Heading } from './heading';
import { Logo } from './logo';
type SidebarElement = React.ElementRef<'aside'>;
type RootProps = React.ComponentPropsWithoutRef<'aside'>;
interface SidebarProps extends RootProps {
navItems: string[];
title?: string;
}
export const Sidebar = React.forwardRef<SidebarElement, Readonly<SidebarProps>>(
({ className, navItems, title, ...props }, forwardedRef) => {
return (
<aside
ref={forwardedRef}
className="px-6 min-w-[275px] max-w-[275px] flex flex-col gap-4 border-r border-slate-6"
{...props}
>
<div className="h-[70px] flex items-center">
<Logo />
</div>
<nav className="flex flex-col gap-4">
<Collapsible.Root defaultOpen>
<Collapsible.Trigger
className={classnames('flex items-center gap-1', {
'cursor-default': navItems && navItems.length === 0,
})}
>
<svg
className="text-slate-11"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.25 17.25V9.75C19.25 8.64543 18.3546 7.75 17.25 7.75H4.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.5 7.5L12.5685 5.7923C12.2181 5.14977 11.5446 4.75 10.8127 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="flex items-center text-slate-11 transition ease-in-out duration-200 hover:text-slate-12">
<Heading
as="h3"
color="gray"
size="2"
weight="medium"
className="transition ease-in-out duration-200 hover:text-slate-12"
>
All emails
</Heading>
{navItems && navItems.length > 0 && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15L8.5359 9.75L15.4641 9.75L12 15Z"
fill="currentColor"
/>
</svg>
)}
</div>
</Collapsible.Trigger>
{navItems && navItems.length > 0 && (
<Collapsible.Content className="relative mt-3">
<div className="absolute left-2.5 w-px h-full bg-slate-6" />
<div className="py-2 flex flex-col truncate">
<LayoutGroup id="sidebar">
{navItems &&
navItems.map((item) => {
const isCurrentPage = title === item;
return (
<Link key={item} href={`/preview/${item}`}>
<motion.span
className={classnames(
'text-[14px] flex items-center font-medium gap-2 w-full pl-4 h-8 rounded-md text-slate-11 relative transition ease-in-out duration-200',
{
'text-cyan-11': isCurrentPage,
'hover:text-slate-12': title !== item,
},
)}
>
{isCurrentPage && (
<motion.span
layoutId="sidebar"
className="absolute left-0 right-0 top-0 bottom-0 rounded-md bg-cyan-5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="bg-cyan-11 w-px absolute top-1 left-2.5 h-6" />
</motion.span>
)}
<svg
className="flex-shrink-0"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.75 19.25H16.25C17.3546 19.25 18.25 18.3546 18.25 17.25V9L14 4.75H7.75C6.64543 4.75 5.75 5.64543 5.75 6.75V17.25C5.75 18.3546 6.64543 19.25 7.75 19.25Z"
stroke="currentColor"
strokeOpacity="0.927"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18 9.25H13.75V5"
stroke="currentColor"
strokeOpacity="0.927"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{item}
</motion.span>
</Link>
);
})}
</LayoutGroup>
</div>
</Collapsible.Content>
)}
</Collapsible.Root>
</nav>
</aside>
);
},
);
Sidebar.displayName = 'Sidebar';

View File

@@ -0,0 +1,100 @@
import * as SlotPrimitive from '@radix-ui/react-slot';
import classnames from 'classnames';
import * as React from 'react';
import { As, unreachable } from '../utils';
export type TextSize = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
export type TextColor = 'gray' | 'white';
export type TextTransform = 'uppercase' | 'lowercase' | 'capitalize';
export type TextWeight = 'normal' | 'medium';
interface TextOwnProps {
size?: TextSize;
color?: TextColor;
transform?: TextTransform;
weight?: TextWeight;
}
type TextProps = As<'span', 'div', 'p'> & TextOwnProps;
export const Text = React.forwardRef<HTMLSpanElement, Readonly<TextProps>>(
(
{
as: Tag = 'span',
size = '2',
color = 'gray',
transform,
weight = 'normal',
className,
children,
...props
},
forwardedRef,
) => (
<SlotPrimitive.Slot
ref={forwardedRef}
className={classnames(
className,
transform,
getSizesClassNames(size),
getColorClassNames(color),
getWeightClassNames(weight),
)}
{...props}
>
<Tag>{children}</Tag>
</SlotPrimitive.Slot>
),
);
const getSizesClassNames = (size: TextSize | undefined) => {
switch (size) {
case '1':
return 'text-xs';
case undefined:
case '2':
return 'text-sm';
case '3':
return 'text-base';
case '4':
return 'text-lg';
case '5':
return ['text-17px', 'md:text-xl tracking-[-0.16px]'];
case '6':
return 'text-2xl tracking-[-0.288px]';
case '7':
return 'text-[28px] leading-[34px] tracking-[-0.416px]';
case '8':
return 'text-[35px] leading-[42px] tracking-[-0.64px]';
case '9':
return 'text-6xl leading-[73px] tracking-[-0.896px]';
default:
return unreachable(size);
}
};
const getColorClassNames = (color: TextColor | undefined) => {
switch (color) {
case 'white':
return 'text-slate-12';
case undefined:
case 'gray':
return 'text-slate-11';
default:
return unreachable(color);
}
};
const getWeightClassNames = (weight: TextWeight | undefined) => {
switch (weight) {
case undefined:
case 'normal':
return 'font-normal';
case 'medium':
return 'font-medium';
default:
return unreachable(weight);
}
};
Text.displayName = 'Text';

View File

@@ -0,0 +1,32 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import classnames from 'classnames';
import * as React from 'react';
import { inter } from '../app/layout';
type ContentElement = React.ElementRef<typeof TooltipPrimitive.Content>;
type ContentProps = React.ComponentPropsWithoutRef<
typeof TooltipPrimitive.Content
>;
export interface TooltipProps extends ContentProps {}
export const TooltipContent = React.forwardRef<
ContentElement,
Readonly<TooltipProps>
>(({ sideOffset = 6, children, ...props }, forwardedRef) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
{...props}
ref={forwardedRef}
className={classnames(
'bg-black border border-slate-6 z-20 px-3 py-2 rounded-md text-xs',
`${inter.variable} font-sans`,
)}
sideOffset={sideOffset}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = 'TooltipContent';

View File

@@ -0,0 +1,19 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from 'react';
import { TooltipContent } from './tooltip-content';
type RootProps = React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>;
export interface TooltipProps extends RootProps {}
export const TooltipRoot: React.FC<Readonly<TooltipProps>> = ({
children,
...props
}) => <TooltipPrimitive.Root {...props}>{children}</TooltipPrimitive.Root>;
export const Tooltip = Object.assign(TooltipRoot, {
Arrow: TooltipPrimitive.TooltipArrow,
Provider: TooltipPrimitive.TooltipProvider,
Content: TooltipContent,
Trigger: TooltipPrimitive.TooltipTrigger,
});

View File

@@ -0,0 +1,112 @@
import * as ToggleGroup from '@radix-ui/react-toggle-group';
import classnames from 'classnames';
import { LayoutGroup, motion } from 'framer-motion';
import * as React from 'react';
import { Heading } from './heading';
import { Send } from './send';
type TopbarElement = React.ElementRef<'header'>;
type RootProps = React.ComponentPropsWithoutRef<'header'>;
interface TopbarProps extends RootProps {
title: string;
activeView?: string;
markup?: string;
setActiveView?: (view: string) => void;
}
export const Topbar = React.forwardRef<TopbarElement, Readonly<TopbarProps>>(
(
{ className, title, markup, activeView, setActiveView, ...props },
forwardedRef,
) => {
const columnWidth = 'w-[200px]';
return (
<header
ref={forwardedRef}
className={classnames(
'bg-black flex relative items-center px-6 justify-between h-[70px] border-b border-slate-6',
className,
)}
{...props}
>
<div className={`flex items-center overflow-hidden ${columnWidth}`}>
<Heading as="h2" size="2" weight="medium" className="truncate">
{title}
</Heading>
</div>
<div className={`${columnWidth}`}>
<LayoutGroup id="topbar">
{setActiveView && (
<ToggleGroup.Root
className="inline-block items-center bg-slate-2 border border-slate-6 rounded-md overflow-hidden"
type="single"
value={activeView}
aria-label="View mode"
onValueChange={(value) => {
if (!value) return;
setActiveView(value);
}}
>
<ToggleGroup.Item value="desktop">
<motion.div
className={classnames(
'text-sm font-medium px-3 py-2 transition ease-in-out duration-200 relative hover:text-slate-12',
{
'text-slate-11': activeView === 'source',
'text-slate-12': activeView === 'desktop',
},
)}
>
{activeView === 'desktop' && (
<motion.span
layoutId="topbar"
className="absolute left-0 right-0 top-0 bottom-0 bg-slate-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
Desktop
</motion.div>
</ToggleGroup.Item>
<ToggleGroup.Item value="source">
<motion.div
className={classnames(
'text-sm font-medium px-3 py-2 transition ease-in-out duration-200 relative hover:text-slate-12',
{
'text-slate-11': activeView === 'desktop',
'text-slate-12': activeView === 'source',
},
)}
>
{activeView === 'source' && (
<motion.span
layoutId="nav"
className="absolute left-0 right-0 top-0 bottom-0 bg-slate-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
Source
</motion.div>
</ToggleGroup.Item>
</ToggleGroup.Root>
)}
</LayoutGroup>
</div>
{markup && (
<div className={`flex justify-end ${columnWidth}`}>
<Send markup={markup} />
</div>
)}
</header>
);
},
);
Topbar.displayName = 'Topbar';

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,26 @@
export type As<
DefaultTag extends React.ElementType,
T1 extends React.ElementType,
T2 extends React.ElementType = T1,
T3 extends React.ElementType = T1,
T4 extends React.ElementType = T1,
T5 extends React.ElementType = T1,
> =
| (React.ComponentPropsWithRef<DefaultTag> & {
as?: DefaultTag;
})
| (React.ComponentPropsWithRef<T1> & {
as: T1;
})
| (React.ComponentPropsWithRef<T2> & {
as: T2;
})
| (React.ComponentPropsWithRef<T3> & {
as: T3;
})
| (React.ComponentPropsWithRef<T4> & {
as: T4;
})
| (React.ComponentPropsWithRef<T5> & {
as: T5;
});

View File

@@ -0,0 +1,7 @@
export const copyTextToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
throw new Error('Not able to copy');
}
};

View File

@@ -0,0 +1,13 @@
import { promises as fs } from 'fs';
import path from 'path';
export const CONTENT_DIR = 'emails';
export const getEmails = async () => {
const emailsDirectory = path.join(process.cwd(), CONTENT_DIR);
const filenames = await fs.readdir(emailsDirectory);
const emails = filenames
.map((file) => file.replace(/\.(jsx|tsx)$/g, ''))
.filter((file) => file !== 'components');
return { emails, filenames };
};

View File

@@ -0,0 +1,3 @@
export * from './as';
export * from './copy-text-to-clipboard';
export * from './unreachable';

View File

@@ -0,0 +1,7 @@
const languageMap = {
jsx: 'React',
markup: 'HTML',
markdown: 'Plain Text',
};
export default languageMap;

View File

@@ -0,0 +1,6 @@
export const unreachable = (
condition: never,
message = `Entered unreachable code. Received '${condition}'.`,
): never => {
throw new TypeError(message);
};

View File

@@ -0,0 +1,90 @@
const colors = require('@radix-ui/colors');
const { fontFamily } = require('tailwindcss/defaultTheme');
const plugin = require('tailwindcss/plugin');
const iOsHeight = plugin(function ({ addUtilities }) {
const supportsTouchRule = '@supports (-webkit-touch-callout: none)';
const webkitFillAvailable = '-webkit-fill-available';
const utilities = {
'.min-h-screen-ios': {
[supportsTouchRule]: {
minHeight: webkitFillAvailable,
},
},
'.h-screen-ios': {
[supportsTouchRule]: {
height: webkitFillAvailable,
},
},
};
addUtilities(utilities, ['responsive']);
});
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
// app content
`src/**/*.{js,ts,jsx,tsx}`,
// include packages if not transpiling
'../../packages/**/*.{js,ts,jsx,tsx}',
'../../apps/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
backgroundImage: {
gradient:
'linear-gradient(145.37deg, rgba(255, 255, 255, 0.09) -8.75%, rgba(255, 255, 255, 0.027) 83.95%)',
gradientHover:
'linear-gradient(145.37deg, rgba(255, 255, 255, 0.1) -8.75%, rgba(255, 255, 255, 0.057) 83.95%)',
shine:
'linear-gradient(45deg, rgba(255,255,255,0) 45%,rgba(255,255,255,1) 50%,rgba(255,255,255,0) 55%,rgba(255,255,255,0) 100%)',
},
colors: {
cyan: {
1: colors.cyanDarkA.cyanA1,
2: colors.cyanDarkA.cyanA2,
3: colors.cyanDarkA.cyanA3,
4: colors.cyanDarkA.cyanA4,
5: colors.cyanDarkA.cyanA5,
6: colors.cyanDarkA.cyanA6,
7: colors.cyanDarkA.cyanA7,
8: colors.cyanDarkA.cyanA8,
9: colors.cyanDarkA.cyanA9,
10: colors.cyanDarkA.cyanA10,
11: colors.cyanDarkA.cyanA11,
12: colors.cyanDarkA.cyanA12,
},
slate: {
1: colors.slateDarkA.slateA1,
2: colors.slateDarkA.slateA2,
3: colors.slateDarkA.slateA3,
4: colors.slateDarkA.slateA4,
5: colors.slateDarkA.slateA5,
6: colors.slateDarkA.slateA6,
7: colors.slateDarkA.slateA7,
8: colors.slateDarkA.slateA8,
9: colors.slateDarkA.slateA9,
10: colors.slateDarkA.slateA10,
11: colors.slateDarkA.slateA11,
12: colors.slateDarkA.slateA12,
},
},
fontFamily: {
sans: ['var(--font-inter)', ...fontFamily.sans],
},
keyframes: {
shine: {
'0%': { backgroundPosition: '-100%' },
'100%': { backgroundPosition: '100%' },
},
dash: {
'0%': { strokeDashoffset: 1000 },
'100%': { strokeDashoffset: 0 },
},
},
},
},
plugins: [iOsHeight],
};

View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
},
"include": [
"preview/next-env.d.ts",
"preview/.next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import baseConfig from "@kan/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: ["dist/**"],
},
...baseConfig,
];

View File

@@ -0,0 +1,37 @@
{
"name": "@kan/email",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"license": "MIT",
"scripts": {
"run:dev": "email dev",
"export": "email export",
"build": "tsc",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@react-email/components": "^0.0.25",
"react-email": "^3.0.1"
},
"devDependencies": {
"@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

27
packages/email/readme.md Normal file
View File

@@ -0,0 +1,27 @@
# React Email Starter
A live preview right in your browser so you don't need to keep sending real emails during development.
## Getting Started
First, install the dependencies:
```sh
npm install
# or
yarn
```
Then, run the development server:
```sh
npm run dev
# or
yarn dev
```
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
## License
MIT License

View File

@@ -0,0 +1,3 @@
export const name = "email";
export { sendEmail } from "./sendEmail";

View File

@@ -0,0 +1,42 @@
import { render } from "@react-email/render";
import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
const emailTemplates: Record<Templates, React.FC> = {
MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate,
};
export const sendEmail = async (
to: string,
subject: string,
template: Templates,
data: Record<string, string>,
) => {
const EmailTemplate = emailTemplates[template];
const html = await render(<EmailTemplate {...data} />, { pretty: true });
const response = await fetch(process.env.EMAIL_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.EMAIL_TOKEN}`,
},
body: JSON.stringify({
from: process.env.EMAIL_FROM,
to,
subject,
html,
}),
});
if (!response.ok) {
throw new Error(`Failed to send email: ${response.statusText}`);
}
return response;
};

Some files were not shown because too many files have changed in this diff Show More