refactor: migrate backend to pnpm monorepo with Hono module architecture

- Replace Bun with pnpm 9 + Turborepo + tsx; apps/api renamed to apps/backend
- Split backend into http/, modules/, lib/ layers mirroring tai-specguard
- Add use-case + Result pattern, Problem Details RFC 7807, basePath /api/v1
- Mount Better Auth at /api/v1/auth, keep session-auth whitelist
- Split Prisma schema into prisma/models/*, generate into generated/
- Rework packages/shared into lib/ + schemas/ with pagination DTOs
- Implement health, groups, students, payments, waitlist modules
- Add vitest suite with prisma mocks (21 tests), biome lint
- Point web client to /api/v1/auth and /api/v1/groups/from-organization
This commit is contained in:
Jose Selesan
2026-09-14 09:18:40 -03:00
parent b41fffa40a
commit 4b1f356fab
101 changed files with 7152 additions and 1456 deletions

View File

@@ -0,0 +1,19 @@
import type { auth } from '@/modules/auth/auth';
type AuthSession = typeof auth.$Infer.Session;
export type BackendEnv = {
Variables: {
user: AuthSession['user'] | null;
session: AuthSession['session'] | null;
requestId: string;
};
};
declare module 'hono' {
interface ContextVariableMap {
user: AuthSession['user'] | null;
session: AuthSession['session'] | null;
requestId: string;
}
}

View File

@@ -0,0 +1,89 @@
import type { ProblemDetails } from '@gruperly/shared';
import { notFoundProblem } from './problem-details';
import { PROBLEM_DOMAIN } from './problem-domain';
export function validationProblem(params: {
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/validation`,
title: 'Bad Request',
status: 400,
detail: params.detail ?? 'Invalid request data',
instance: params.instance,
errors: params.errors,
};
}
export function conflictProblem(params: {
detail: string;
code?: string;
instance?: string;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/conflict`,
title: 'Conflict',
status: 409,
detail: params.detail,
instance: params.instance,
code: params.code,
};
}
export function forbiddenProblem(params: {
detail: string;
code?: string;
instance?: string;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/forbidden`,
title: 'Forbidden',
status: 403,
detail: params.detail,
instance: params.instance,
code: params.code,
};
}
export function organizationNotFoundProblem(id: string): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
title: 'Not Found',
status: 404,
detail: `Organization ${id} was not found.`,
code: 'organization_not_found',
};
}
export function groupOwnerRequiredProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'Only the organization owner can create the group.',
code: 'group_owner_required',
});
}
export function databaseUnavailableProblem(params?: {
instance?: string;
}): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/database-unavailable`,
title: 'Service Unavailable',
status: 503,
detail: 'Database health check failed',
instance: params?.instance,
code: 'database_unavailable',
};
}
export function noGroupAccessProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'You do not have access to this group.',
code: 'group_access_denied',
});
}
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
return notFoundProblem(`${resourceName} ${id}`);
}

View File

@@ -0,0 +1,82 @@
import type { ProblemDetails, Result } from '@gruperly/shared';
import type { Context } from 'hono';
import type {
ClientErrorStatusCode,
ServerErrorStatusCode,
SuccessStatusCode,
} from 'hono/utils/http-status';
type ProblemStatusCode = ClientErrorStatusCode | ServerErrorStatusCode;
type JsonSuccessStatusCode = Exclude<SuccessStatusCode, 204 | 205>;
type ProblemDetailsInput = Omit<ProblemDetails, 'status' | 'title'> & {
status: ProblemStatusCode;
title: string;
};
type ResultJsonOptions = {
status?: JsonSuccessStatusCode;
};
const DEFAULT_TYPE = 'about:blank';
export const problemDetails = ({
type = DEFAULT_TYPE,
title,
status,
detail,
instance,
code,
}: ProblemDetailsInput): ProblemDetails => ({
type,
title,
status,
...(detail ? { detail } : {}),
...(instance ? { instance } : {}),
...(code ? { code } : {}),
});
export const problemJson = (c: Context, problem: ProblemDetails) => {
return c.json(problem, problem.status as ProblemStatusCode, {
'Content-Type': 'application/problem+json',
});
};
export const resultJson = <TValue>(
c: Context,
result: Result<TValue, ProblemDetails>,
{ status = 200 }: ResultJsonOptions = {},
) => {
if (!result.ok) {
return problemJson(c, result.error);
}
return c.json(result.value, status);
};
export const notFoundProblem = (instance: string): ProblemDetails =>
problemDetails({
title: 'Not Found',
status: 404,
detail: 'The requested resource was not found.',
instance,
code: 'not_found',
});
export const internalServerErrorProblem = (instance: string): ProblemDetails =>
problemDetails({
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred.',
instance,
code: 'internal_server_error',
});
export const unauthorizedProblem = (instance: string): ProblemDetails =>
problemDetails({
title: 'Unauthorized',
status: 401,
detail: 'Authentication is required to access this resource.',
instance,
code: 'unauthorized',
});

View File

@@ -0,0 +1 @@
export const PROBLEM_DOMAIN = process.env.PROBLEM_DOMAIN_URL ?? 'https://gruperly.com';

View File

@@ -0,0 +1,12 @@
import type { MiddlewareHandler } from 'hono';
import { runWithRequestLogContext } from '@/logger';
import type { BackendEnv } from './env';
export const requestIdMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
const requestId = crypto.randomUUID();
c.set('requestId', requestId);
c.header('X-Request-Id', requestId);
await runWithRequestLogContext(requestId, next);
};

View File

@@ -0,0 +1,22 @@
import type { MiddlewareHandler } from 'hono';
import { logger } from '@/logger';
import type { BackendEnv } from './env';
export const requestLoggerMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
const startedAt = Date.now();
try {
await next();
} finally {
logger.info(
{
method: c.req.method,
path: c.req.path,
requestId: c.get('requestId'),
status: c.res.status,
durationMs: Date.now() - startedAt,
},
'Request completed',
);
}
};

View File

@@ -0,0 +1,34 @@
import type { MiddlewareHandler } from 'hono';
import { cors as honoCors } from 'hono/cors';
function getWebOrigin(): string {
return process.env.WEB_URL ?? 'http://localhost:6173';
}
export const corsMiddleware: MiddlewareHandler = honoCors({
origin: (requestOrigin) => {
const allowed = getWebOrigin();
if (requestOrigin === allowed) {
return requestOrigin;
}
return '';
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
credentials: true,
});
export const securityHeadersMiddleware: MiddlewareHandler = async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('X-XSS-Protection', '0');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (process.env.NODE_ENV === 'production') {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
};

View File

@@ -0,0 +1,43 @@
import type { MiddlewareHandler } from 'hono';
import { auth } from '@/modules/auth/auth';
import type { BackendEnv } from './env';
import { problemJson, unauthorizedProblem } from './problem-details';
export const sessionAuthMiddleware: MiddlewareHandler<BackendEnv> = async (c, next) => {
if (isPublicApiRequest(c.req.method, c.req.path)) {
await next();
return;
}
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
c.set('user', session.user);
c.set('session', session.session);
await next();
};
export function isPublicApiRequest(method: string, path: string): boolean {
if (method === 'OPTIONS') {
return true;
}
const normalizedPath = normalizePath(path);
return (
normalizedPath === '/api/v1/health'
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth')
|| matchesPublicPrefix(normalizedPath, '/api/auth')
);
}
function matchesPublicPrefix(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`);
}
function normalizePath(path: string): string {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
}

View File

@@ -0,0 +1,57 @@
import { zValidator } from '@hono/zod-validator';
import type { Context } from 'hono';
import type { ZodIssue, ZodSchema } from 'zod';
import { validationProblem } from './problem-builders';
import { zodIssuesToRecord } from './zod-issues';
type ValidationTarget = 'json' | 'query' | 'param' | 'header' | 'form';
function makeValidator(target: ValidationTarget, schema: ZodSchema) {
return zValidator(
target,
schema,
(result: { success: boolean; error?: { issues: ZodIssue[] } }, c: Context) => {
if (result.success) {
return;
}
const requestId = c.get('requestId');
const instance = requestId ? `/requests/${requestId}` : undefined;
const issues = result.error?.issues ?? [];
return c.json(
validationProblem({
detail: `Invalid ${target} data`,
instance,
errors: zodIssuesToRecord(issues),
}),
400,
{
'Content-Type': 'application/problem+json',
},
);
},
);
}
export const validate = {
json(schema: ZodSchema) {
return makeValidator('json', schema);
},
query(schema: ZodSchema) {
return makeValidator('query', schema);
},
param(schema: ZodSchema) {
return makeValidator('param', schema);
},
header(schema: ZodSchema) {
return makeValidator('header', schema);
},
form(schema: ZodSchema) {
return makeValidator('form', schema);
},
};

View File

@@ -0,0 +1,16 @@
type IssueLike = {
path: PropertyKey[];
message: string;
};
export function zodIssuesToRecord(issues: IssueLike[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const issue of issues) {
const key = issue.path.length ? issue.path.join('.') : 'root';
out[key] ??= [];
out[key].push(issue.message);
}
return out;
}