This commit is contained in:
Turbobot
2024-03-21 13:41:16 +08:00
committed by giancarlo
commit bb58169fe9
204 changed files with 26228 additions and 0 deletions

55
packages/api/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@acme/api",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.mjs",
"./edge": "./src/edge.ts",
"./lambda": "./src/lambda.ts",
"./transformer": "./src/transformer.ts",
"./validators": "./src/validators.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@acme/db": "^0.1.0",
"@acme/stripe": "^0.1.0",
"@clerk/nextjs": "^4.29.4",
"@dinero.js/currencies": "2.0.0-alpha.14",
"@t3-oss/env-nextjs": "^0.7.3",
"@trpc/client": "next",
"@trpc/server": "next",
"dinero.js": "2.0.0-alpha.14",
"superjson": "2.2.1",
"zod": "^3.22.4",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@acme/eslint-config": "^0.2.0",
"@acme/prettier-config": "^0.1.0",
"@acme/tsconfig": "^0.1.0",
"eslint": "^8.56.0",
"prettier": "^3.2.4",
"typescript": "^5.3.3"
},
"eslintConfig": {
"root": true,
"extends": [
"@acme/eslint-config/base"
]
},
"prettier": "@acme/prettier-config"
}

13
packages/api/src/edge.ts Normal file
View File

@@ -0,0 +1,13 @@
import { authRouter } from "./router/auth";
import { organizationsRouter } from "./router/organizations";
import { projectRouter } from "./router/project";
import { stripeRouter } from "./router/stripe";
import { createTRPCRouter } from "./trpc";
// Deployed to /trpc/edge/**
export const edgeRouter = createTRPCRouter({
project: projectRouter,
auth: authRouter,
stripe: stripeRouter,
organization: organizationsRouter,
});

19
packages/api/src/env.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { createEnv } from "@t3-oss/env-nextjs";
import * as z from "zod";
export const env = createEnv({
shared: {},
server: {
NEXTJS_URL: z.preprocess(
(str) =>
process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str,
process.env.VERCEL_URL ? z.string().min(1) : z.string().url(),
),
},
// Client side variables gets destructured here due to Next.js static analysis
// Shared ones are also included here for good measure since the behavior has been inconsistent
experimental__runtimeEnv: {},
skipValidation:
!!process.env.SKIP_ENV_VALIDATION ||
process.env.npm_lifecycle_event === "lint",
});

22
packages/api/src/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "./root";
export { createTRPCContext, createInnerTRPCContext } from "./trpc";
// TODO: Maybe just export `createAction` instead of the whole `trpc` object?
export { t } from "./trpc";
export type { AppRouter } from "./root";
export { appRouter } from "./root";
/**
* Inference helpers for input types
* @example type HelloInput = RouterInputs['example']['hello']
**/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helpers for output types
* @example type HelloOutput = RouterOutputs['example']['hello']
**/
export type RouterOutputs = inferRouterOutputs<AppRouter>;

View File

@@ -0,0 +1,7 @@
import { ingestionRouter } from "./router/ingestion";
import { createTRPCRouter } from "./trpc";
// Deployed to /trpc/lambda/**
export const lambdaRouter = createTRPCRouter({
ingestion: ingestionRouter,
});

8
packages/api/src/root.ts Normal file
View File

@@ -0,0 +1,8 @@
import { edgeRouter } from "./edge";
import { lambdaRouter } from "./lambda";
import { mergeRouters } from "./trpc";
// Used to provide a good DX with a single client
// Then, a custom link is used to generate the correct URL for the request
export const appRouter = mergeRouters(edgeRouter, lambdaRouter);
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,28 @@
import { clerkClient } from "@clerk/nextjs";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const authRouter = createTRPCRouter({
mySubscription: protectedProcedure.query(async (opts) => {
const customer = await opts.ctx.db
.selectFrom("Customer")
.select(["plan", "endsAt"])
.where("clerkUserId", "=", opts.ctx.auth.userId)
.executeTakeFirst();
if (!customer) return null;
return { plan: customer.plan ?? null, endsAt: customer.endsAt ?? null };
}),
listOrganizations: protectedProcedure.query(async (opts) => {
const memberships = await clerkClient.users.getOrganizationMembershipList({
userId: opts.ctx.auth.userId,
});
return memberships.map(({ organization }) => ({
id: organization.id,
name: organization.name,
image: organization.imageUrl,
}));
}),
});

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { genId } from "@acme/db";
import {
createTRPCRouter,
protectedApiFormDataProcedure,
protectedProcedure,
} from "../trpc";
globalThis.File = File;
const myFileValidator = z.preprocess(
// @ts-expect-error - this is a hack. not sure why it's needed since it should already be a File
(file: File) =>
new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
}),
zfd.file(z.instanceof(File)),
);
/**
* FIXME: Not all of these have to run on lambda, just the upload one
*/
export const ingestionRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async (opts) => {
const ingestion = await opts.ctx.db
.selectFrom("Ingestion")
.select(["id", "createdAt", "hash", "schema", "origin", "parent"])
.where("id", "=", opts.input.id)
.executeTakeFirstOrThrow();
return ingestion;
}),
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().optional(),
}),
)
.query(async (opts) => {
let query = opts.ctx.db
.selectFrom("Ingestion")
.select(["id", "createdAt", "hash"])
.where("projectId", "=", opts.input.projectId);
if (opts.input.limit) {
query = query.limit(opts.input.limit).orderBy("createdAt", "desc");
}
const ingestions = await query.execute();
return ingestions.map((ingestion) => ({
...ingestion,
adds: Math.floor(Math.random() * 10),
subs: Math.floor(Math.random() * 10),
}));
}),
upload: protectedApiFormDataProcedure
.input(
zfd.formData({
hash: zfd.text(),
parent: zfd.text().optional(),
origin: zfd.text(),
schema: myFileValidator,
}),
)
.mutation(async (opts) => {
const fileContent = await opts.input.schema.text();
const id = "ingest_" + genId();
await opts.ctx.db
.insertInto("Ingestion")
.values({
id,
projectId: opts.ctx.apiKey.projectId,
hash: opts.input.hash,
parent: opts.input.parent,
origin: opts.input.origin,
schema: fileContent,
apiKeyId: opts.ctx.apiKey.id,
})
.executeTakeFirst();
return { status: "ok" };
}),
});

View File

@@ -0,0 +1,97 @@
import { clerkClient } from "@clerk/nextjs";
import { TRPCError } from "@trpc/server";
import * as z from "zod";
import {
createTRPCRouter,
protectedAdminProcedure,
protectedOrgProcedure,
} from "../trpc";
import { inviteOrgMemberSchema } from "../validators";
export const organizationsRouter = createTRPCRouter({
listMembers: protectedOrgProcedure.query(async (opts) => {
const { orgId } = opts.ctx.auth;
const members =
await clerkClient.organizations.getOrganizationMembershipList({
organizationId: orgId,
});
return members.map((member) => ({
id: member.id,
email: member.publicUserData?.identifier ?? "",
role: member.role,
joinedAt: member.createdAt,
avatarUrl: member.publicUserData?.imageUrl,
name: [
member.publicUserData?.firstName,
member.publicUserData?.lastName,
].join(" "),
}));
}),
deleteMember: protectedAdminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async (opts) => {
const { orgId } = opts.ctx.auth;
try {
const member =
await clerkClient.organizations.deleteOrganizationMembership({
organizationId: orgId,
userId: opts.input.userId,
});
return { memberName: member.publicUserData?.firstName };
} catch (e) {
console.log("Error deleting member", e);
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
}),
inviteMember: protectedAdminProcedure
.input(inviteOrgMemberSchema)
.mutation(async (opts) => {
const { orgId } = opts.ctx.auth;
const { email } = opts.input;
const users = await clerkClient.users.getUserList({
emailAddress: [email],
});
const user = users[0];
if (users.length === 0 || !user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (users.length > 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Multiple users found with that email address",
});
}
const member =
await clerkClient.organizations.createOrganizationMembership({
organizationId: orgId,
userId: user.id,
role: opts.input.role,
});
const { firstName, lastName } = member.publicUserData ?? {};
return { name: [firstName, lastName].join(" ") };
}),
deleteOrganization: protectedAdminProcedure.mutation(async (opts) => {
const { orgId } = opts.ctx.auth;
await clerkClient.organizations.deleteOrganization(orgId);
}),
});

View File

@@ -0,0 +1,414 @@
import { clerkClient } from "@clerk/nextjs";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { genId } from "@acme/db";
import {
createTRPCRouter,
protectedAdminProcedure,
protectedProcedure,
} from "../trpc";
import {
createApiKeySchema,
createProjectSchema,
renameProjectSchema,
transferToOrgSchema,
} from "../validators";
const PROJECT_LIMITS = {
FREE: 1,
PRO: 3,
} as const;
export const projectRouter = createTRPCRouter({
create: protectedProcedure
.input(createProjectSchema)
.mutation(async (opts) => {
const { userId, orgId } = opts.ctx.auth;
const { name } = opts.input;
// Check if limit is reached
let query = opts.ctx.db
.selectFrom("Project")
.select(({ fn }) => [fn.count<number>("id").as("projects")]);
if (orgId) {
query = query.where("organizationId", "=", orgId);
} else {
query = query.where("userId", "=", userId);
}
const projects = (await query.executeTakeFirst())?.projects ?? 0;
// FIXME: Don't hardcode the limit to PRO
if (projects >= PROJECT_LIMITS.PRO) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Limit reached" });
}
const projectId = "project_" + genId();
await opts.ctx.db
.insertInto("Project")
.values({
id: projectId,
name,
userId: orgId ? null : userId,
organizationId: orgId,
})
.execute();
return projectId;
}),
rename: protectedProcedure
.input(renameProjectSchema)
.mutation(async (opts) => {
const { projectId, name } = opts.input;
// TODO: Validate permissions, should anyone with access to the project be able to change the name?
await opts.ctx.db
.updateTable("Project")
.set({
name,
})
.where("id", "=", projectId)
.execute();
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async (opts) => {
const { userId, orgId } = opts.ctx.auth;
const deleteQuery = opts.ctx.db
.deleteFrom("Project")
.where("id", "=", opts.input.id);
// TODO: Check billing etc
if (orgId) {
// TODO: Check permissions
await deleteQuery.where("organizationId", "=", orgId).execute();
} else {
await deleteQuery.where("userId", "=", userId).execute();
}
}),
transferToPersonal: protectedAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async (opts) => {
const project = await opts.ctx.db
.selectFrom("Project")
.select(["id", "userId", "organizationId"])
.where("id", "=", opts.input.id)
.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (!project.organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Project is already personal",
});
}
await opts.ctx.db
.updateTable("Project")
.set({
userId: opts.ctx.auth.userId,
organizationId: null,
})
.where("id", "=", project.id)
.execute();
}),
transferToOrganization: protectedProcedure
.input(transferToOrgSchema)
.mutation(async (opts) => {
const { userId, orgId: userOrgId, orgRole } = opts.ctx.auth;
const { orgId: targetOrgId } = opts.input;
const orgs = await clerkClient.users.getOrganizationMembershipList({
userId: userId,
});
const org = orgs.find((org) => org.organization.id === targetOrgId);
if (!org) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You're not a member of the target organization",
});
}
const project = await opts.ctx.db
.selectFrom("Project")
.select(["id", "userId", "organizationId"])
.where(({ eb, and, or }) =>
and([
eb("id", "=", opts.input.projectId),
or([
eb("userId", "=", userId),
eb("organizationId", "=", userOrgId ?? ""),
]),
]),
)
.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (project.organizationId === targetOrgId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Project is already in the target organization",
});
}
if (
project.organizationId &&
project.organizationId !== userOrgId &&
orgRole !== "admin"
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be an admin to transfer this project",
});
}
await opts.ctx.db
.updateTable("Project")
.set({
userId: null,
organizationId: targetOrgId,
})
.where("id", "=", project.id)
.execute();
}),
listByActiveWorkspace: protectedProcedure.query(async (opts) => {
const { userId, orgId } = opts.ctx.auth;
let query = opts.ctx.db
.selectFrom("Project")
.select(["id", "name", "url", "tier"]);
if (orgId) {
query = query.where("organizationId", "=", orgId);
} else {
query = query.where("userId", "=", userId);
}
const projects = await query.execute();
// FIXME: Don't hardcode the limit to PRO
return {
projects,
limit: PROJECT_LIMITS.PRO,
limitReached: projects.length >= PROJECT_LIMITS.PRO,
};
}),
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async (opts) => {
const { userId } = opts.ctx.auth;
const { id } = opts.input;
const orgs = await clerkClient.users.getOrganizationMembershipList({
userId: userId,
});
const orgIds = orgs.map((org) => org.organization.id);
// Verify the user has access to the project
const query = opts.ctx.db
.selectFrom("Project")
.select(["id", "name", "url", "tier", "organizationId"])
.where(({ eb, and, or }) =>
and([
eb("id", "=", id),
orgIds.length > 0
? or([
eb("userId", "=", userId),
eb("organizationId", "in", orgIds),
])
: eb("userId", "=", userId),
]),
);
const project = await query.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
return project;
}),
listApiKeys: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async (opts) => {
const { userId } = opts.ctx.auth;
const { projectId } = opts.input;
const apiKeys = await opts.ctx.db
.selectFrom("ApiKey")
.select([
"id",
"name",
"key",
"createdAt",
"lastUsed",
"expiresAt",
"revokedAt",
])
.where("projectId", "=", projectId)
.where("clerkUserId", "=", userId)
// first active, then expired, then revoked
.orderBy((eb) =>
eb
.case()
.when("revokedAt", "is not", null)
.then(3)
.when(
eb.and([
eb("expiresAt", "is not", null),
eb("expiresAt", "<", new Date()),
]),
)
.then(2)
.else(1)
.end(),
)
.orderBy("createdAt", "desc")
.execute();
// TODO: Project admins should maybe be able to see all keys for the project?
return apiKeys;
}),
createApiKey: protectedProcedure
.input(createApiKeySchema)
.mutation(async (opts) => {
const projectId = opts.input.projectId;
const userId = opts.ctx.auth.userId;
// Verify the user has access to the project
const project = await opts.ctx.db
.selectFrom("Project")
.select(["id", "name", "userId", "organizationId"])
.where("id", "=", projectId)
.executeTakeFirst();
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
if (project.userId && project.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this project",
});
}
if (project.organizationId) {
const orgs = await clerkClient.users.getOrganizationMembershipList({
userId,
});
const isMemberInProjectOrg = orgs.some(
(org) => org.organization.id === project.organizationId,
);
if (!isMemberInProjectOrg) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this project",
});
}
}
// Generate the key
const apiKey = "sk_live_" + genId();
const apiKeyId = "api_key_" + genId();
await opts.ctx.db
.insertInto("ApiKey")
.values({
id: apiKeyId,
name: opts.input.name,
key: apiKey,
expiresAt: opts.input.expiresAt,
projectId: opts.input.projectId,
clerkUserId: userId,
})
.execute();
return apiKey;
}),
revokeApiKeys: protectedProcedure
.input(z.object({ ids: z.string().array() }))
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const result = await opts.ctx.db
.updateTable("ApiKey")
.set({ revokedAt: new Date() })
.where("id", "in", opts.input.ids)
.where("clerkUserId", "=", String(userId))
.where("revokedAt", "is", null)
.executeTakeFirst();
if (result.numUpdatedRows === BigInt(0)) {
throw new TRPCError({
code: "NOT_FOUND",
message: "API key not found",
});
}
return { success: true, numRevoked: result.numUpdatedRows };
}),
rollApiKey: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async (opts) => {
const apiKey = await opts.ctx.db
.selectFrom("ApiKey")
.select(["id"])
.where("id", "=", opts.input.id)
.where("clerkUserId", "=", opts.ctx.auth.userId)
.executeTakeFirst();
if (!apiKey) {
throw new TRPCError({
code: "NOT_FOUND",
message: "API key not found",
});
}
const newKey = "sk_live_" + genId();
await opts.ctx.db
.updateTable("ApiKey")
.set({ key: newKey })
.where("id", "=", opts.input.id)
.execute();
return newKey;
}),
});

View File

@@ -0,0 +1,111 @@
import { currentUser } from "@clerk/nextjs";
import * as currencies from "@dinero.js/currencies";
import { dinero } from "dinero.js";
import * as z from "zod";
import { PLANS, stripe } from "@acme/stripe";
import { env } from "../env.mjs";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { purchaseOrgSchema } from "../validators";
export const stripeRouter = createTRPCRouter({
createSession: protectedProcedure
.input(z.object({ planId: z.string() }))
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const customer = await opts.ctx.db
.selectFrom("Customer")
.select(["id", "plan", "stripeId"])
.where("clerkUserId", "=", userId)
.executeTakeFirst();
const returnUrl = env.NEXTJS_URL + "/dashboard";
if (customer && customer.plan !== "FREE") {
/**
* User is subscribed, create a billing portal session
*/
const session = await stripe.billingPortal.sessions.create({
customer: customer.stripeId,
return_url: returnUrl,
});
return { success: true as const, url: session.url };
}
/**
* User is not subscribed, create a checkout session
* Use existing email address if available
*/
const user = await currentUser();
const email = user?.emailAddresses.find(
(addr) => addr.id === user?.primaryEmailAddressId,
)?.emailAddress;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: email,
client_reference_id: userId,
subscription_data: { metadata: { userId } },
cancel_url: returnUrl,
success_url: returnUrl,
line_items: [{ price: PLANS.PRO?.priceId, quantity: 1 }],
});
if (!session.url) return { success: false as const };
return { success: true as const, url: session.url };
}),
plans: publicProcedure.query(async () => {
const proPrice = await stripe.prices.retrieve(PLANS.PRO.priceId);
const stdPrice = await stripe.prices.retrieve(PLANS.STANDARD.priceId);
return [
{
...PLANS.STANDARD,
price: dinero({
amount: stdPrice.unit_amount!,
currency:
currencies[stdPrice.currency as keyof typeof currencies] ??
currencies.USD,
}),
},
{
...PLANS.PRO,
price: dinero({
amount: proPrice.unit_amount!,
currency:
currencies[proPrice.currency as keyof typeof currencies] ??
currencies.USD,
}),
},
];
}),
purchaseOrg: protectedProcedure
.input(purchaseOrgSchema)
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const { orgName, planId } = opts.input;
const baseUrl = new URL(opts.ctx.req?.nextUrl ?? env.NEXTJS_URL).origin;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
client_reference_id: userId,
subscription_data: {
metadata: { userId, organizationName: orgName },
},
success_url: baseUrl + "/onboarding",
cancel_url: baseUrl,
line_items: [{ price: planId, quantity: 1 }],
});
if (!session.url) return { success: false as const };
return { success: true as const, url: session.url };
}),
});

View File

@@ -0,0 +1,30 @@
import { dinero } from "dinero.js";
import type { Dinero, DineroSnapshot } from "dinero.js";
import superjson from "superjson";
import type { JSONValue } from "superjson/dist/types";
/**
* TODO: Maybe put this in a shared package that can be safely shared between `api`, `nextjs` and `expo` packages
*/
superjson.registerCustom(
{
isApplicable: (val): val is Dinero<number> => {
try {
// if this doesn't crash we're kinda sure it's a Dinero instance
(val as Dinero<number>).calculator.add(1, 2);
return true;
} catch {
return false;
}
},
serialize: (val) => {
return val.toJSON() as JSONValue;
},
deserialize: (val) => {
return dinero(val as DineroSnapshot<number>);
},
},
"Dinero",
);
export const transformer = superjson;

224
packages/api/src/trpc.ts Normal file
View File

@@ -0,0 +1,224 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1)
* 2. You want to create a new middleware or type of procedure (see Part 3)
*
* tl;dr - this is where all the tRPC server stuff is created and plugged in.
* The pieces you will need to use are documented accordingly near the end
*/
import type { NextRequest } from "next/server";
import type {
SignedInAuthObject,
SignedOutAuthObject,
} from "@clerk/nextjs/server";
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import { db } from "@acme/db";
import { transformer } from "./transformer";
type AuthContext = SignedInAuthObject | SignedOutAuthObject;
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API
*
* These allow you to access things like the database, the session, etc, when
* processing a request
*
*/
interface CreateContextOptions {
headers: Headers;
auth: AuthContext;
apiKey?: string | null;
req?: NextRequest;
}
/**
* This helper generates the "internals" for a tRPC context. If you need to use
* it, you can export it from here
*
* Examples of things you may need it for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
*/
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
...opts,
db,
};
};
/**
* This is the actual context you'll use in your router. It will be used to
* process every request that goes through your tRPC endpoint
* @link https://trpc.io/docs/context
*/
export const createTRPCContext = async (opts: {
headers: Headers;
auth: AuthContext;
req?: NextRequest;
// eslint-disable-next-line @typescript-eslint/require-await
}) => {
const apiKey = opts.req?.headers.get("x-acme-api-key");
return createInnerTRPCContext({
auth: opts.auth,
apiKey,
req: opts.req,
headers: opts.headers,
});
};
/**
* 2. INITIALIZATION
*
* This is where the trpc api is initialized, connecting the context and
* transformer
*/
export const t = initTRPC.context<typeof createTRPCContext>().create({
transformer,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these
* a lot in the /src/server/api/routers folder
*/
/**
* This is how you create new routers and subrouters in your tRPC API
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
export const mergeRouters = t.mergeRouters;
/**
* Public (unauthed) procedure
*
* This is the base piece you use to build new queries and mutations on your
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
export const publicProcedure = t.procedure;
/**
* Reusable procedure that enforces users are logged in before running the
* code
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.auth?.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
auth: {
...ctx.auth,
userId: ctx.auth.userId,
},
},
});
});
/**
* Reusable procedure that enforces users are part of an organization before
* running the code
*/
export const protectedOrgProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!ctx.auth?.orgId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be in an organization to perform this action",
});
}
return next({
ctx: {
auth: {
...ctx.auth,
orgId: ctx.auth.orgId,
},
},
});
});
/**
* Procedure that enforces users are admins of an organization before running
* the code
*/
export const protectedAdminProcedure = protectedOrgProcedure.use(
({ ctx, next }) => {
if (ctx.auth.orgRole !== "admin") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be an admin to perform this action",
});
}
return next({
ctx: {
auth: {
...ctx.auth,
orgRole: ctx.auth.orgRole,
},
},
});
},
);
/**
* Procedure to authenticate API requests with an API key
*/
export const protectedApiProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.apiKey) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Check db for API key
const apiKey = await ctx.db
.selectFrom("ApiKey")
.select(["id", "key", "projectId"])
.where("ApiKey.key", "=", ctx.apiKey)
.where("revokedAt", "is", null)
.executeTakeFirst();
if (!apiKey) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
void ctx.db
.updateTable("ApiKey")
.set({ lastUsed: new Date() })
.where("id", "=", apiKey.id)
.execute();
return next({
ctx: {
apiKey,
},
});
});
/**
* Procedure to parse form data and put it in the rawInput and authenticate requests with an API key
*/
export const protectedApiFormDataProcedure = protectedApiProcedure.use(
async function formData(opts) {
const formData = await opts.ctx.req?.formData?.();
if (!formData) throw new TRPCError({ code: "BAD_REQUEST" });
return opts.next({
input: formData,
});
},
);

View File

@@ -0,0 +1,55 @@
import * as z from "zod";
import { PLANS } from "@acme/stripe/plans";
/**
* Shared validators used in both the frontend and backend
*/
export const createProjectSchema = z.object({
name: z.string().min(5, "Name must be at least 5 characters"),
url: z.string().url("Must be a valid URL").optional(),
});
export type CreateProject = z.infer<typeof createProjectSchema>;
export const renameProjectSchema = z.object({
projectId: z.string(),
name: z.string().min(5, "Name must be at least 5 characters"),
});
export type RenameProject = z.infer<typeof renameProjectSchema>;
export const purchaseOrgSchema = z.object({
orgName: z.string().min(5, "Name must be at least 5 characters"),
planId: z.string().refine(
(str) =>
Object.values(PLANS)
.map((p) => p.priceId)
.includes(str),
"Invalid planId",
),
});
export type PurchaseOrg = z.infer<typeof purchaseOrgSchema>;
export const createApiKeySchema = z.object({
projectId: z.string(),
name: z.string(),
expiresAt: z.date().optional(),
});
export type CreateApiKey = z.infer<typeof createApiKeySchema>;
export const MEMBERSHIP = {
Member: "basic_member",
Admin: "admin",
} as const;
export const inviteOrgMemberSchema = z.object({
email: z.string().email(),
role: z.nativeEnum(MEMBERSHIP),
});
export type InviteOrgMember = z.infer<typeof inviteOrgMemberSchema>;
export const transferToOrgSchema = z.object({
projectId: z.string(),
orgId: z.string(),
});
export type TransferToOrg = z.infer<typeof transferToOrgSchema>;

View File

@@ -0,0 +1,8 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["src"],
"exclude": ["node_modules"],
}