import { TRPCError, initTRPC } from "@trpc/server" import { CreateNextContextOptions } from "@trpc/server/adapters/next" import jwt from "jsonwebtoken" import { OpenApiMeta } from "trpc-swagger" import { v4 as uuid } from "uuid" import { z } from "zod" import { Post, User, database } from "./database" const jwtSecret = uuid() export type Context = { user: User | null requestId: string } const t = initTRPC .context() .meta() .create({ errorFormatter: ({ error, shape }) => { if (error.code === "INTERNAL_SERVER_ERROR" && process.env.NODE_ENV === "production") { return { ...shape, message: "Internal server error" } } return shape } }) // eslint-disable-next-line @typescript-eslint/require-await export const createContext = async ({ req, res }: CreateNextContextOptions): Promise => { const requestId = uuid() res.setHeader("x-request-id", requestId) let user: User | null = null try { if (req.headers.authorization) { const token = req.headers.authorization.split(" ")[1] const userId = jwt.verify(token, jwtSecret) as string if (userId) { user = database.users.find((_user) => _user.id === userId) ?? null } } } catch (cause) { console.error(cause) } return { user, requestId } } const publicProcedure = t.procedure const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ message: "User not found", code: "UNAUTHORIZED" }) } return next({ ctx: { ...ctx, user: ctx.user } }) }) const authRouter = t.router({ register: publicProcedure .meta({ openapi: { method: "POST", path: "/auth/register", tags: ["auth"], summary: "Register as a new user" } }) .input( z.object({ email: z.string().email(), passcode: z.preprocess( (arg) => (typeof arg === "string" ? parseInt(arg) : arg), z.number().min(1000).max(9999) ), name: z.string().min(3) }) ) .output( z.object({ user: z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(3) }) }) ) .mutation(({ input }) => { let user = database.users.find((_user) => _user.email === input.email) if (user) { throw new TRPCError({ message: "User with email already exists", code: "UNAUTHORIZED" }) } user = { id: uuid(), email: input.email, passcode: input.passcode, name: input.name } database.users.push(user) return { user: { id: user.id, email: user.email, name: user.name } } }), login: publicProcedure .meta({ openapi: { method: "POST", path: "/auth/login", tags: ["auth"], summary: "Login as an existing user" } }) .input( z.object({ email: z.string().email(), passcode: z.preprocess( (arg) => (typeof arg === "string" ? parseInt(arg) : arg), z.number().min(1000).max(9999) ) }) ) .output( z.object({ token: z.string() }) ) .mutation(({ input }) => { const user = database.users.find((_user) => _user.email === input.email) if (!user) { throw new TRPCError({ message: "User with email not found", code: "UNAUTHORIZED" }) } if (user.passcode !== input.passcode) { throw new TRPCError({ message: "Passcode was incorrect", code: "UNAUTHORIZED" }) } return { token: jwt.sign(user.id, jwtSecret) } }) }) const usersRouter = t.router({ getUsers: publicProcedure .meta({ openapi: { method: "GET", path: "/users", tags: ["users"], summary: "Read all users" } }) .input(z.void()) .output( z.object({ users: z.array( z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string() }) ) }) ) .query(() => { const users = database.users.map((user) => ({ id: user.id, email: user.email, name: user.name })) return { users } }), getUserById: publicProcedure .meta({ openapi: { method: "GET", path: "/users/{id}", tags: ["users"], summary: "Read a user by id" } }) .input( z.object({ id: z.string().uuid() }) ) .output( z.object({ user: z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string() }) }) ) .query(({ input }) => { const user = database.users.find((_user) => _user.id === input.id) if (!user) { throw new TRPCError({ message: "User not found", code: "NOT_FOUND" }) } return { user } }) }) const postsRouter = t.router({ getPosts: publicProcedure .meta({ openapi: { method: "GET", path: "/posts", tags: ["posts"], summary: "Read all posts" } }) .input( z.object({ userId: z.string().uuid().optional() }) ) .output( z.object({ posts: z.array( z.object({ id: z.string().uuid(), content: z.string(), userId: z.string().uuid() }) ) }) ) .query(({ input }) => { let { posts } = database if (input.userId) { posts = posts.filter((post) => { return post.userId === input.userId }) } return { posts } }), getPostById: publicProcedure .meta({ openapi: { method: "GET", path: "/posts/{id}", tags: ["posts"], summary: "Read a post by id" } }) .input( z.object({ id: z.string().uuid() }) ) .output( z.object({ post: z.object({ id: z.string().uuid(), content: z.string(), userId: z.string().uuid() }) }) ) .query(({ input }) => { const post = database.posts.find((_post) => _post.id === input.id) if (!post) { throw new TRPCError({ message: "Post not found", code: "NOT_FOUND" }) } return { post } }), createPost: protectedProcedure .meta({ openapi: { method: "POST", path: "/posts", tags: ["posts"], protect: true, summary: "Create a new post" } }) .input( z.object({ content: z.string().min(1).max(140) }) ) .output( z.object({ post: z.object({ id: z.string().uuid(), content: z.string(), userId: z.string().uuid() }) }) ) .mutation(({ input, ctx }) => { const post: Post = { id: uuid(), content: input.content, userId: ctx.user.id } database.posts.push(post) return { post } }), updatePostById: protectedProcedure .meta({ openapi: { method: "PUT", path: "/posts/{id}", tags: ["posts"], protect: true, summary: "Update an existing post" } }) .input( z.object({ id: z.string().uuid(), content: z.string().min(1) }) ) .output( z.object({ post: z.object({ id: z.string().uuid(), content: z.string(), userId: z.string().uuid() }) }) ) .mutation(({ input, ctx }) => { const post = database.posts.find((_post) => _post.id === input.id) if (!post) { throw new TRPCError({ message: "Post not found", code: "NOT_FOUND" }) } if (post.userId !== ctx.user.id) { throw new TRPCError({ message: "Cannot edit post owned by other user", code: "FORBIDDEN" }) } post.content = input.content return { post } }), deletePostById: protectedProcedure .meta({ openapi: { method: "DELETE", path: "/posts/{id}", tags: ["posts"], protect: true, summary: "Delete a post" } }) .input( z.object({ id: z.string().uuid() }) ) .output(z.null()) .mutation(({ input, ctx }) => { const post = database.posts.find((_post) => _post.id === input.id) if (!post) { throw new TRPCError({ message: "Post not found", code: "NOT_FOUND" }) } if (post.userId !== ctx.user.id) { throw new TRPCError({ message: "Cannot delete post owned by other user", code: "FORBIDDEN" }) } database.posts = database.posts.filter((_post) => _post !== post) return null }) }) export const appRouter = t.router({ auth: authRouter, users: usersRouter, posts: postsRouter }) export type AppRouter = typeof appRouter