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