feat(create-turbo): create https://github.com/juliusmarminge/acme-corp
This commit is contained in:
13
packages/api/src/edge.ts
Normal file
13
packages/api/src/edge.ts
Normal 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
19
packages/api/src/env.mjs
Normal 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
22
packages/api/src/index.ts
Normal 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>;
|
||||
7
packages/api/src/lambda.ts
Normal file
7
packages/api/src/lambda.ts
Normal 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
8
packages/api/src/root.ts
Normal 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;
|
||||
28
packages/api/src/router/auth.ts
Normal file
28
packages/api/src/router/auth.ts
Normal 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,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
93
packages/api/src/router/ingestion.ts
Normal file
93
packages/api/src/router/ingestion.ts
Normal 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" };
|
||||
}),
|
||||
});
|
||||
97
packages/api/src/router/organizations.ts
Normal file
97
packages/api/src/router/organizations.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
414
packages/api/src/router/project.ts
Normal file
414
packages/api/src/router/project.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
111
packages/api/src/router/stripe.ts
Normal file
111
packages/api/src/router/stripe.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
30
packages/api/src/transformer.ts
Normal file
30
packages/api/src/transformer.ts
Normal 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
224
packages/api/src/trpc.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
55
packages/api/src/validators.ts
Normal file
55
packages/api/src/validators.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user