Openstatus
www.openstatus.dev
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);