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