first project commit

This commit is contained in:
2026-05-16 12:02:15 +01:00
parent 33789b13d1
commit c013b52f59
79 changed files with 5834 additions and 0 deletions
+29
View File
@@ -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"
}
}
+45
View File
@@ -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 }),
};
}
+3
View File
@@ -0,0 +1,3 @@
export { appRouter, type AppRouter } from './routers/_app';
export { createTRPCContext, type Context, type SessionUser } from './context';
export { createCallerFactory } from './trpc';
+11
View File
@@ -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,
});
+8
View File
@@ -0,0 +1,8 @@
import { router } from '../trpc';
import { pingRouter } from './ping';
export const appRouter = router({
ping: pingRouter,
});
export type AppRouter = typeof appRouter;
+31
View File
@@ -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(),
};
}),
});
+38
View File
@@ -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,
},
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}