feat: setup openapi compliant rest api
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"@vercel/postgres": "^0.7.2",
|
||||
"drizzle-orm": "^0.28.5",
|
||||
"next": "^14.1.3",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
@@ -42,6 +43,7 @@
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"superjson": "^1.13.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"trpc-to-openapi": "^2.0.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
30
src/pages/api/v1/[trpc].ts
Normal file
30
src/pages/api/v1/[trpc].ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import cors from "nextjs-cors";
|
||||
|
||||
import { env } from "~/env.mjs";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createRESTContext } from "~/server/api/trpc";
|
||||
import { createOpenApiNextHandler } from "trpc-to-openapi";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await cors(req, res);
|
||||
|
||||
const openApiHandler = createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createRESTContext,
|
||||
responseMeta: () => ({ headers: {} }),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return await openApiHandler(req, res);
|
||||
}
|
||||
9
src/pages/api/v1/openapi.json.ts
Normal file
9
src/pages/api/v1/openapi.json.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
import { openApiDocument } from "~/server/openapi";
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).send(openApiDocument);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -12,21 +12,52 @@ import { env } from "~/env.mjs";
|
||||
import * as userRepo from "~/server/db/repository/user.repo";
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
getUser: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
getUser: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/auth/user",
|
||||
summary: "Get user",
|
||||
},
|
||||
})
|
||||
.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",
|
||||
});
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const result = await userRepo.getById(ctx.db, userId);
|
||||
const result = await userRepo.getById(ctx.db, userId);
|
||||
|
||||
return result;
|
||||
}),
|
||||
if (!result?.name) {
|
||||
throw new TRPCError({
|
||||
message: `User not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
loginWithEmail: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/auth/email",
|
||||
summary: "Login with email",
|
||||
},
|
||||
})
|
||||
.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,
|
||||
@@ -35,10 +66,24 @@ export const authRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
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/oauth",
|
||||
summary: "Login with OAuth",
|
||||
},
|
||||
})
|
||||
.input(z.object({ provider: z.string() }))
|
||||
.output(z.object({ url: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.provider !== "google")
|
||||
throw new TRPCError({
|
||||
@@ -57,6 +102,12 @@ export const authRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
if (!data?.url)
|
||||
throw new TRPCError({
|
||||
message: `Failed to login with OAuth`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return { url: data.url };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,17 @@ import * as workspaceRepo from "~/server/db/repository/workspace.repo";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/board/{workspacePublicId}",
|
||||
summary: "Get all boards",
|
||||
},
|
||||
})
|
||||
.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,
|
||||
@@ -28,31 +38,48 @@ export const boardRouter = createTRPCRouter({
|
||||
return result;
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/board/{boardPublicId}",
|
||||
summary: "Get board by public ID",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
filters: z.object({
|
||||
members: z.array(z.string().min(12)),
|
||||
labels: z.array(z.string().min(12)),
|
||||
}),
|
||||
members: z.array(z.string().min(12)),
|
||||
labels: z.array(z.string().min(12)),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
input.filters,
|
||||
{
|
||||
members: input.members,
|
||||
labels: input.labels,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/board",
|
||||
summary: "Create board",
|
||||
},
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -79,29 +106,57 @@ export const boardRouter = createTRPCRouter({
|
||||
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: "/board/{boardPublicId}",
|
||||
summary: "Update board",
|
||||
},
|
||||
})
|
||||
.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: "/board/{boardPublicId}",
|
||||
summary: "Delete board",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -145,5 +200,7 @@ export const boardRouter = createTRPCRouter({
|
||||
deletedBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,13 @@ import * as workspaceRepo from "~/server/db/repository/workspace.repo";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a card",
|
||||
method: "POST",
|
||||
path: "/",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
@@ -20,6 +27,7 @@ export const cardRouter = createTRPCRouter({
|
||||
position: z.enum(["start", "end"]),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -65,6 +73,12 @@ export const cardRouter = createTRPCRouter({
|
||||
|
||||
const newCardId = newCard?.id;
|
||||
|
||||
if (!newCardId)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
if (newCardId && input.labelPublicIds.length) {
|
||||
const labels = await labelRepo.getAllByPublicIds(
|
||||
ctx.db,
|
||||
@@ -111,12 +125,20 @@ export const cardRouter = createTRPCRouter({
|
||||
return newCard;
|
||||
}),
|
||||
addOrRemoveLabel: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add or remove a label from a card",
|
||||
method: "POST",
|
||||
path: "/{cardPublicId}/label/{labelPublicId}",
|
||||
},
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -159,12 +181,20 @@ export const cardRouter = createTRPCRouter({
|
||||
return { newLabel: true };
|
||||
}),
|
||||
addOrRemoveMember: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add or remove a member from a card",
|
||||
method: "POST",
|
||||
path: "/{cardPublicId}/member/{workspaceMemberPublicId}",
|
||||
},
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -210,16 +240,41 @@ export const cardRouter = createTRPCRouter({
|
||||
return { newMember: true };
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get a card by ID",
|
||||
method: "GET",
|
||||
path: "/{id}",
|
||||
},
|
||||
})
|
||||
.input(z.object({ id: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await cardRepo.getWithListAndMembersByPublicId(
|
||||
ctx.db,
|
||||
input.id,
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Card with ID ${input.id} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a card",
|
||||
method: "PUT",
|
||||
path: "/{cardId}",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardId: z.string().min(12),
|
||||
@@ -227,6 +282,7 @@ export const cardRouter = createTRPCRouter({
|
||||
description: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -236,20 +292,34 @@ export const cardRouter = createTRPCRouter({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const result = cardRepo.update(
|
||||
const result = await cardRepo.update(
|
||||
ctx.db,
|
||||
{ title: input.title, description: input.description },
|
||||
{ cardPublicId: input.cardId },
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update card`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a card",
|
||||
method: "DELETE",
|
||||
path: "/{cardPublicId}",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -282,8 +352,17 @@ export const cardRouter = createTRPCRouter({
|
||||
listId: card.list.id,
|
||||
cardIndex: card.index,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
reorder: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Reorder a card",
|
||||
method: "POST",
|
||||
path: "/{cardId}/reorder",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardId: z.string().min(12),
|
||||
@@ -291,6 +370,7 @@ export const cardRouter = createTRPCRouter({
|
||||
newIndex: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -37,12 +37,20 @@ interface MemberData {
|
||||
export const importRouter = createTRPCRouter({
|
||||
trello: createTRPCRouter({
|
||||
getBoards: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get boards from Trello",
|
||||
method: "GET",
|
||||
path: "/boards",
|
||||
},
|
||||
})
|
||||
.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}`,
|
||||
@@ -79,6 +87,13 @@ export const importRouter = createTRPCRouter({
|
||||
}));
|
||||
}),
|
||||
importBoards: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Import boards from Trello",
|
||||
method: "POST",
|
||||
path: "/import",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardIds: z.array(z.string()),
|
||||
@@ -87,6 +102,7 @@ export const importRouter = createTRPCRouter({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ boardsCreated: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -8,7 +8,15 @@ import * as labelRepo from "~/server/db/repository/label.repo";
|
||||
|
||||
export const labelRouter = createTRPCRouter({
|
||||
byPublicId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get a label by public ID",
|
||||
method: "GET",
|
||||
path: "/{publicId}",
|
||||
},
|
||||
})
|
||||
.input(z.object({ publicId: z.string().min(12) }))
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.getByPublicId>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const label = await labelRepo.getByPublicId(ctx.db, input.publicId);
|
||||
|
||||
@@ -21,6 +29,13 @@ export const labelRouter = createTRPCRouter({
|
||||
return label;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a label",
|
||||
method: "POST",
|
||||
path: "/create",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(36),
|
||||
@@ -28,6 +43,7 @@ export const labelRouter = createTRPCRouter({
|
||||
colourCode: z.string().length(7),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.create>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -55,9 +71,22 @@ export const labelRouter = createTRPCRouter({
|
||||
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: "/{publicId}",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
publicId: z.string().min(12),
|
||||
@@ -65,13 +94,22 @@ export const labelRouter = createTRPCRouter({
|
||||
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: "/{publicId}",
|
||||
},
|
||||
})
|
||||
.input(z.object({ publicId: z.string().min(12) }))
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const label = await labelRepo.getByPublicId(ctx.db, input.publicId);
|
||||
|
||||
|
||||
@@ -9,12 +9,20 @@ import * as listRepo from "~/server/db/repository/list.repo";
|
||||
|
||||
export const listRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a list",
|
||||
method: "POST",
|
||||
path: "/list/create",
|
||||
},
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -44,9 +52,22 @@ export const listRouter = createTRPCRouter({
|
||||
index: latestListIndex ? 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: "/{listId}/reorder",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardId: z.string().min(12),
|
||||
@@ -55,6 +76,7 @@ export const listRouter = createTRPCRouter({
|
||||
newIndex: z.number(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof listRepo.reorder>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const list = await listRepo.getByPublicId(ctx.db, input.listId);
|
||||
|
||||
@@ -64,21 +86,35 @@ export const listRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = listRepo.reorder(ctx.db, {
|
||||
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: "/{listPublicId}",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
listPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -114,14 +150,24 @@ export const listRouter = createTRPCRouter({
|
||||
boardId: list.boardId,
|
||||
listIndex: list.id,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a list",
|
||||
method: "PUT",
|
||||
path: "/list/{listPublicId}",
|
||||
},
|
||||
})
|
||||
.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,
|
||||
@@ -129,6 +175,12 @@ export const listRouter = createTRPCRouter({
|
||||
{ listPublicId: input.listPublicId },
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update list`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -11,12 +11,20 @@ import { sendEmail } from "~/email/sendEmail";
|
||||
|
||||
export const memberRouter = createTRPCRouter({
|
||||
invite: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Invite a member to a workspace",
|
||||
method: "POST",
|
||||
path: "/invite",
|
||||
},
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -132,11 +140,19 @@ export const memberRouter = createTRPCRouter({
|
||||
return invite;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a member from a workspace",
|
||||
method: "DELETE",
|
||||
path: "/{memberPublicId}",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
memberPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -163,6 +179,12 @@ export const memberRouter = createTRPCRouter({
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
return deletedMember;
|
||||
if (!deletedMember)
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete member with public ID ${input.memberPublicId}`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,35 +5,73 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import * as workspaceRepo from "~/server/db/repository/workspace.repo";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
all: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get all workspaces",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
},
|
||||
})
|
||||
.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",
|
||||
});
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const result = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
const result = await workspaceRepo.getAllByUserId(ctx.db, userId);
|
||||
|
||||
return result;
|
||||
}),
|
||||
return result;
|
||||
}),
|
||||
byId: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get a workspace by public ID",
|
||||
method: "GET",
|
||||
path: "/workspace/{publicId}",
|
||||
},
|
||||
})
|
||||
.input(z.object({ publicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
|
||||
>(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await workspaceRepo.getByPublicIdWithMembers(
|
||||
ctx.db,
|
||||
input.publicId,
|
||||
);
|
||||
|
||||
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: "/workspace/create",
|
||||
},
|
||||
})
|
||||
.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;
|
||||
|
||||
@@ -58,12 +96,20 @@ export const workspaceRouter = createTRPCRouter({
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a workspace",
|
||||
method: "PUT",
|
||||
path: "/workspace/{workspacePublicId}",
|
||||
},
|
||||
})
|
||||
.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,
|
||||
@@ -74,13 +120,27 @@ export const workspaceRouter = createTRPCRouter({
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a workspace",
|
||||
method: "DELETE",
|
||||
path: "/workspace/{workspacePublicId}",
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { data } = await workspaceRepo.hardDelete(
|
||||
const result = await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
return data;
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Unable to delete workspace`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
* need to use are documented accordingly near the end.
|
||||
*/
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||
import { type OpenApiMeta } from "trpc-to-openapi";
|
||||
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { createTRPCClient, createTRPCAdminClient } from "~/utils/supabase/api";
|
||||
import {
|
||||
createNextApiClient,
|
||||
createTRPCClient,
|
||||
createTRPCAdminClient,
|
||||
} from "~/utils/supabase/api";
|
||||
import { type Database } from "~/types/database.types";
|
||||
import { type SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
@@ -72,6 +79,20 @@ export const createTRPCContext = async ({
|
||||
return createInnerTRPCContext({ db, adminDb, user });
|
||||
};
|
||||
|
||||
export const createRESTContext = async ({
|
||||
req,
|
||||
res,
|
||||
}: CreateNextContextOptions) => {
|
||||
const db = createNextApiClient(req, res);
|
||||
const adminDb = createTRPCAdminClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await db.auth.getUser();
|
||||
|
||||
return createInnerTRPCContext({ db, adminDb, user });
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
@@ -80,19 +101,22 @@ export const createTRPCContext = async ({
|
||||
* errors on the backend.
|
||||
*/
|
||||
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a server-side caller.
|
||||
@@ -122,7 +146,9 @@ export const createTRPCRouter = t.router;
|
||||
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||
* are logged in.
|
||||
*/
|
||||
export const publicProcedure = t.procedure;
|
||||
export const publicProcedure = t.procedure.meta({
|
||||
openapi: { method: "GET", path: "/public" },
|
||||
});
|
||||
|
||||
/** Reusable middleware that enforces users are logged in before running the procedure. */
|
||||
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
@@ -143,4 +169,9 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
*
|
||||
* @see https://trpc.io/docs/procedures
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/protected",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getAllByWorkspaceId = async (
|
||||
.is("deletedAt", null)
|
||||
.eq("workspaceId", workspaceId);
|
||||
|
||||
return data;
|
||||
return data ?? [];
|
||||
};
|
||||
|
||||
export const getByPublicId = async (
|
||||
@@ -157,7 +157,10 @@ export const update = async (
|
||||
const { data } = await db
|
||||
.from("board")
|
||||
.update({ name: boardInput.name })
|
||||
.eq("publicId", boardInput.boardPublicId);
|
||||
.eq("publicId", boardInput.boardPublicId)
|
||||
.select(`publicId, name`)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ export const getAllByUserId = async (
|
||||
db: SupabaseClient<Database>,
|
||||
userId: string,
|
||||
) => {
|
||||
const { data, error } = await db
|
||||
const { data } = await db
|
||||
.from("workspace_members")
|
||||
.select(
|
||||
`
|
||||
@@ -116,9 +116,7 @@ export const getAllByUserId = async (
|
||||
.eq("userId", userId)
|
||||
.is("deletedAt", null);
|
||||
|
||||
console.log({ error });
|
||||
|
||||
return data;
|
||||
return data ?? [];
|
||||
};
|
||||
|
||||
export const getMemberByPublicId = async (
|
||||
|
||||
12
src/server/openapi.ts
Normal file
12
src/server/openapi.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { generateOpenApiDocument } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter } from "~/server/api/root";
|
||||
|
||||
export const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||
title: "Kan API",
|
||||
description: "OpenAPI compliant REST API",
|
||||
version: "1.0.0",
|
||||
baseUrl: "http://localhost:3000/api/v1",
|
||||
docsUrl: "",
|
||||
tags: ["auth", "users", "posts"],
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { RequestCookies } from "@edge-runtime/cookies";
|
||||
import { type Database } from "~/types/database.types";
|
||||
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import { type NextRequest, type NextResponse } from "next/server";
|
||||
|
||||
export function createNextClient(req: NextRequest, res: NextResponse) {
|
||||
@@ -30,6 +31,32 @@ export function createNextClient(req: NextRequest, res: NextResponse) {
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export function createNextApiClient(
|
||||
_req: NextApiRequest,
|
||||
_res: NextApiResponse,
|
||||
) {
|
||||
const supabase = createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_API_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
get(_name: string) {
|
||||
// return req.cookies.get(name)?.value;
|
||||
return "";
|
||||
},
|
||||
set(_name: string, _value: string, _options: CookieOptions) {
|
||||
// res.headers.append("Set-Cookie", serialize(name, value, options));
|
||||
},
|
||||
remove(_name: string, _options: CookieOptions) {
|
||||
// res.headers.append("Set-Cookie", serialize(name, "", options));
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export function createTRPCClient(req: Request, resHeaders: Headers) {
|
||||
const supabase = createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
|
||||
@@ -61,10 +61,8 @@ export default function BoardPage() {
|
||||
const { data, isSuccess, isLoading } = api.board.byId.useQuery(
|
||||
{
|
||||
boardPublicId: boardId ?? "",
|
||||
filters: {
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
},
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
},
|
||||
{
|
||||
enabled: !!boardId,
|
||||
|
||||
@@ -54,9 +54,9 @@ export function LabelForm({
|
||||
const { control, register, reset, handleSubmit, setValue, watch } =
|
||||
useForm<LabelFormInput>({
|
||||
values: {
|
||||
name: isEdit && label.data?.name ? label.data.name : "",
|
||||
colour: (isEdit && label.data?.colourCode
|
||||
? colours.find((c) => c.code === label.data.colourCode)
|
||||
name: isEdit && label?.data?.name ? label?.data?.name : "",
|
||||
colour: (isEdit && label?.data?.colourCode
|
||||
? colours.find((c) => c.code === label?.data?.colourCode)
|
||||
: colours[0]) as Colour,
|
||||
isCreateAnotherEnabled: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user