first project commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@repo/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./context": "./src/context.ts",
|
||||
"./trpc": "./src/trpc.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf .turbo node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/db": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"pino": "^9.5.0",
|
||||
"superjson": "^2.2.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/config": "workspace:*",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { prisma, tenantScoped, type TenantScopedClient, type DbClient } from '@repo/db';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Authenticated user shape passed in by the app layer.
|
||||
*
|
||||
* @repo/api does NOT depend on next-auth directly — that would entangle the API
|
||||
* layer with a specific auth implementation. Instead, the Next route handler
|
||||
* resolves Auth.js's Session and adapts it into this minimal shape.
|
||||
*/
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: 'ADMIN' | 'SUPERVISOR' | 'OPERATOR';
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
export type CreateContextOptions = {
|
||||
user: SessionUser | null;
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
/** Unscoped Prisma client. Use only for cross-tenant operations (e.g. login lookup). */
|
||||
prisma: DbClient;
|
||||
/** Tenant-scoped Prisma client. NULL when there's no authenticated tenant. */
|
||||
db: TenantScopedClient | null;
|
||||
/** Authenticated user, or null. */
|
||||
user: SessionUser | null;
|
||||
/** Tenant id (convenience). */
|
||||
tenantId: string | null;
|
||||
headers: Headers;
|
||||
logger: typeof logger;
|
||||
};
|
||||
|
||||
export async function createTRPCContext({ user, headers }: CreateContextOptions): Promise<Context> {
|
||||
return {
|
||||
prisma,
|
||||
db: user ? tenantScoped(prisma, user.tenantId) : null,
|
||||
user,
|
||||
tenantId: user?.tenantId ?? null,
|
||||
headers,
|
||||
logger: logger.child({ tenantId: user?.tenantId ?? null, userId: user?.id ?? null }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { appRouter, type AppRouter } from './routers/_app';
|
||||
export { createTRPCContext, type Context, type SessionUser } from './context';
|
||||
export { createCallerFactory } from './trpc';
|
||||
@@ -0,0 +1,11 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
base: undefined,
|
||||
// Pretty-print only in development; in production emit JSON for log aggregation.
|
||||
transport:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:HH:MM:ss' } }
|
||||
: undefined,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { router } from '../trpc';
|
||||
import { pingRouter } from './ping';
|
||||
|
||||
export const appRouter = router({
|
||||
ping: pingRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
export const pingRouter = router({
|
||||
/**
|
||||
* End-to-end smoke test:
|
||||
* client → tRPC → Prisma → Postgres → tenant fetched → response.
|
||||
* Returns the current tenant so the caller can confirm scoping works.
|
||||
*/
|
||||
ping: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Tenant is not in TENANT_SCOPED_MODELS so the extension passes this
|
||||
// through; we still go via ctx.db to keep call sites uniform.
|
||||
const tenant = await ctx.db.tenant.findUnique({
|
||||
where: { id: ctx.tenantId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Tenant ${ctx.tenantId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
tenant,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
import type { Context } from './context';
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
/** Public — no auth required. */
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
/** Protected — requires an authenticated session with a tenantId. */
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.user || !ctx.db || !ctx.tenantId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.user,
|
||||
db: ctx.db,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user