Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 277 lines 7.6 kB view raw
1import { TRPCError, initTRPC } from "@trpc/server"; 2import { type NextRequest, after } from "next/server"; 3import superjson from "superjson"; 4import { ZodError, treeifyError } from "zod"; 5 6import { 7 type EventProps, 8 type IdentifyProps, 9 parseInputToProps, 10 setupAnalytics, 11} from "@openstatus/analytics"; 12import { db, eq, schema } from "@openstatus/db"; 13import type { User, Workspace } from "@openstatus/db/src/schema"; 14 15// Generic session type that works with both User and Viewer 16type Session = { 17 user?: { 18 id?: string | null; 19 email?: string | null; 20 } | null; 21} | null; 22 23/** 24 * 1. CONTEXT 25 * 26 * This section defines the "contexts" that are available in the backend API 27 * 28 * These allow you to access things like the database, the session, etc, when 29 * processing a request 30 * 31 */ 32type CreateContextOptions = { 33 session: Session | null; 34 workspace?: Workspace | null; 35 user?: User | null; 36 req?: NextRequest; 37 metadata?: { 38 userAgent?: string; 39 location?: string; 40 }; 41}; 42 43type Meta = { 44 track?: EventProps; 45 trackProps?: string[]; 46}; 47 48/** 49 * This helper generates the "internals" for a tRPC context. If you need to use 50 * it, you can export it from here 51 * 52 * Examples of things you may need it for: 53 * - testing, so we dont have to mock Next.js' req/res 54 * - trpc's `createSSGHelpers` where we don't have req/res 55 * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts 56 */ 57export const createInnerTRPCContext = (opts: CreateContextOptions) => { 58 return { 59 ...opts, 60 db, 61 }; 62}; 63 64/** 65 * This is the actual context you'll use in your router. It will be used to 66 * process every request that goes through your tRPC endpoint 67 * @link https://trpc.io/docs/context 68 */ 69export const createTRPCContext = async (opts: { 70 req: NextRequest; 71 serverSideCall?: boolean; 72 auth?: () => Promise<Session>; 73}) => { 74 // Use provided auth function or return null session 75 const session = opts.auth ? await opts.auth() : null; 76 const workspace = null; 77 const user = null; 78 79 return createInnerTRPCContext({ 80 session, 81 workspace, 82 user, 83 req: opts.req, 84 metadata: { 85 userAgent: opts.req.headers.get("user-agent") ?? undefined, 86 location: 87 opts.req.headers.get("x-forwarded-for") ?? 88 process.env.VERCEL_REGION ?? 89 undefined, 90 }, 91 }); 92}; 93 94export type Context = Awaited<ReturnType<typeof createTRPCContext>>; 95 96/** 97 * 2. INITIALIZATION 98 * 99 * This is where the trpc api is initialized, connecting the context and 100 * transformer 101 */ 102export const t = initTRPC 103 .context<Context>() 104 .meta<Meta>() 105 .create({ 106 transformer: superjson, 107 errorFormatter({ shape, error }) { 108 return { 109 ...shape, 110 data: { 111 ...shape.data, 112 zodError: 113 error.cause instanceof ZodError ? treeifyError(error.cause) : null, 114 }, 115 }; 116 }, 117 }); 118 119/** 120 * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 121 * 122 * These are the pieces you use to build your tRPC API. You should import these 123 * a lot in the /src/server/api/routers folder 124 */ 125 126/** 127 * This is how you create new routers and subrouters in your tRPC API 128 * @see https://trpc.io/docs/router 129 */ 130export const createTRPCRouter = t.router; 131export const mergeRouters = t.mergeRouters; 132 133/** 134 * Public (unauthed) procedure 135 * 136 * This is the base piece you use to build new queries and mutations on your 137 * tRPC API. It does not guarantee that a user querying is authorized, but you 138 * can still access user session data if they are logged in 139 */ 140export const publicProcedure = t.procedure; 141 142/** 143 * Reusable middleware that enforces users are logged in before running the 144 * procedure 145 */ 146const enforceUserIsAuthed = t.middleware(async (opts) => { 147 const { ctx } = opts; 148 if (!ctx.session?.user?.id) { 149 throw new TRPCError({ code: "UNAUTHORIZED" }); 150 } 151 152 // /** 153 // * Attach `user` and `workspace` | `activeWorkspace` infos to context by 154 // * comparing the `user.tenantId` to clerk's `auth.userId` 155 // */ 156 const userAndWorkspace = await db.query.user.findFirst({ 157 where: eq(schema.user.id, Number(ctx.session.user.id)), 158 with: { 159 usersToWorkspaces: { 160 with: { 161 workspace: true, 162 }, 163 }, 164 }, 165 }); 166 167 const { usersToWorkspaces, ...userProps } = userAndWorkspace || {}; 168 169 /** 170 * We need to include the active "workspace-slug" cookie in the request found in the 171 * `/app/[workspaceSlug]/.../`routes. We pass them either via middleware if it's a 172 * server request or via the client cookie, set via `<WorspaceClientCookie />` 173 * if it's a client request. 174 * 175 * REMINDER: We only need the client cookie because of client side mutations. 176 */ 177 const workspaceSlug = ctx.req?.cookies.get("workspace-slug")?.value; 178 179 // if (!workspaceSlug) { 180 // throw new TRPCError({ 181 // code: "UNAUTHORIZED", 182 // message: "Workspace Slug Not Found", 183 // }); 184 // } 185 186 // NOTE: if no workspace slug fit (cookie manipulation), use the first workspace 187 const activeWorkspace = 188 usersToWorkspaces?.find(({ workspace }) => { 189 // If there is a workspace slug in the cookie, use it to find the workspace 190 if (workspaceSlug) return workspace.slug === workspaceSlug; 191 return true; 192 })?.workspace ?? usersToWorkspaces?.[0]?.workspace; 193 194 if (!activeWorkspace) { 195 throw new TRPCError({ 196 code: "UNAUTHORIZED", 197 message: "Workspace Not Found", 198 }); 199 } 200 201 if (activeWorkspace.slug !== workspaceSlug) { 202 // properly set the workspace slug cookie 203 ctx.req?.cookies.set("workspace-slug", activeWorkspace.slug); 204 } 205 206 if (!userProps) { 207 throw new TRPCError({ code: "UNAUTHORIZED", message: "User Not Found" }); 208 } 209 210 const user = schema.selectUserSchema.parse(userProps); 211 const workspace = schema.selectWorkspaceSchema.parse(activeWorkspace); 212 213 const result = await opts.next({ ctx: { ...ctx, user, workspace } }); 214 215 if (process.env.NODE_ENV === "test") { 216 return result; 217 } 218 219 // REMINDER: We only track the event if the request was successful 220 if (!result.ok) { 221 return result; 222 } 223 224 // REMINDER: We only track the event if the request was successful 225 // REMINDER: We are not blocking the request 226 after(async () => { 227 const { ctx, meta, getRawInput } = opts; 228 229 if (meta?.track) { 230 let identify: IdentifyProps = { 231 userAgent: ctx.metadata?.userAgent, 232 location: ctx.metadata?.location, 233 }; 234 235 if (user && workspace) { 236 identify = { 237 ...identify, 238 userId: `usr_${user.id}`, 239 email: user.email || undefined, 240 workspaceId: String(workspace.id), 241 plan: workspace.plan, 242 }; 243 } 244 245 const analytics = await setupAnalytics(identify); 246 const rawInput = await getRawInput(); 247 const additionalProps = parseInputToProps(rawInput, meta.trackProps); 248 249 await analytics.track({ ...meta.track, ...additionalProps }); 250 } 251 }); 252 253 return result; 254}); 255 256/** 257 * Middleware to parse form data and put it in the rawInput 258 */ 259export const formdataMiddleware = t.middleware(async (opts) => { 260 const formData = await opts.ctx.req?.formData?.(); 261 if (!formData) throw new TRPCError({ code: "BAD_REQUEST" }); 262 263 return opts.next({ 264 input: formData, 265 }); 266}); 267 268/** 269 * Protected (authed) procedure 270 * 271 * If you want a query or mutation to ONLY be accessible to logged in users, use 272 * this. It verifies the session is valid and guarantees ctx.session.user is not 273 * null 274 * 275 * @see https://trpc.io/docs/procedures 276 */ 277export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);