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

@@ -1,3 +1,9 @@
export * from './schemas/group.schema'
export * from './schemas/student.schema'
export * from './schemas/payment.schema'
export * from './lib/problem-details.js';
export * from './lib/result.js';
export * from './schemas/groups.js';
export * from './schemas/health-check.js';
export * from './schemas/pagination.js';
export * from './schemas/payments.js';
export * from './schemas/problem-details.js';
export * from './schemas/students.js';
export * from './schemas/waitlist.js';

View File

@@ -0,0 +1,9 @@
export type ProblemDetails = {
type?: string;
title: string;
status: number;
detail?: string;
instance?: string;
code?: string;
errors?: Record<string, string[]>;
};

View File

@@ -0,0 +1,13 @@
export type Result<TValue, TError> =
| { ok: true; value: TValue }
| { ok: false; error: TError };
export const ok = <TValue>(value: TValue): Result<TValue, never> => ({
ok: true,
value,
});
export const err = <TError>(error: TError): Result<never, TError> => ({
ok: false,
error,
});

View File

@@ -1,15 +0,0 @@
import { z } from 'zod'
export const createGroupSchema = z.object({
name: z.string().min(1, 'El nombre es obligatorio'),
description: z.string().optional(),
})
export const updateGroupSchema = createGroupSchema.partial()
export const groupIdParamsSchema = z.object({
id: z.string().min(1),
})
export type CreateGroupInput = z.infer<typeof createGroupSchema>
export type UpdateGroupInput = z.infer<typeof updateGroupSchema>

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const GroupDtoSchema = z.object({
id: z.string(),
name: z.string().min(1).max(100),
description: z.string().nullable(),
createdById: z.string(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type GroupDto = z.output<typeof GroupDtoSchema>;
export const GroupQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type GroupQuery = z.output<typeof GroupQuerySchema>;
export const GroupListSchema = z.object({
data: z.array(GroupDtoSchema),
pagination: paginationSchema,
});
export type GroupList = z.output<typeof GroupListSchema>;
export const CreateGroupFromOrganizationSchema = z.strictObject({
organizationId: z.string().min(1),
});
export type CreateGroupFromOrganization = z.output<typeof CreateGroupFromOrganizationSchema>;
export const CreateGroupFromOrganizationResultSchema = z.object({
group: GroupDtoSchema,
alreadyExists: z.boolean(),
});
export type CreateGroupFromOrganizationResult = z.output<
typeof CreateGroupFromOrganizationResultSchema
>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const HealthCheckSchema = z.object({
status: z.string(),
timestamp: z.string().datetime(),
checks: z.object({
database: z.string(),
}),
});
export type HealthCheck = z.output<typeof HealthCheckSchema>;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export function createPageSchema() {
return z.coerce.number().int().min(1).default(1);
}
export function createPageSizeSchema(defaultValue: number = 10) {
return z.coerce.number().int().min(1).max(100).default(defaultValue);
}
export function createPaginationSchema() {
return z.object({
page: z.number().int().min(1),
pageSize: z.number().int().min(1).max(100),
total: z.number().int().min(0),
totalPages: z.number().int().min(0),
});
}

View File

@@ -1,17 +0,0 @@
import { z } from 'zod'
export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCELLED'])
export const createPaymentSchema = z.object({
groupId: z.string().min(1),
studentId: z.string().min(1),
amount: z.coerce.number().positive(),
currency: z.string().default('MXN'),
dueDate: z.coerce.date(),
status: paymentStatusSchema.optional(),
})
export const updatePaymentSchema = createPaymentSchema.partial()
export type CreatePaymentInput = z.infer<typeof createPaymentSchema>
export type UpdatePaymentInput = z.infer<typeof updatePaymentSchema>

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCELLED']);
export const PaymentDtoSchema = z.object({
id: z.string(),
groupId: z.string(),
studentId: z.string(),
amount: z.coerce.number().positive(),
currency: z.string(),
status: paymentStatusSchema,
dueDate: isoDateTimeSchema,
paidAt: isoDateTimeSchema.nullable(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type PaymentDto = z.output<typeof PaymentDtoSchema>;
export const PaymentQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type PaymentQuery = z.output<typeof PaymentQuerySchema>;
export const PaymentListSchema = z.object({
data: z.array(PaymentDtoSchema),
pagination: paginationSchema,
});
export type PaymentList = z.output<typeof PaymentListSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import type { ProblemDetails } from '../lib/problem-details.js';
export const ProblemDetailsSchema: z.ZodType<ProblemDetails> = z.object({
type: z.string().optional(),
title: z.string(),
status: z.number().int().min(100).max(599),
detail: z.string().optional(),
instance: z.string().optional(),
code: z.string().optional(),
errors: z.record(z.string(), z.array(z.string())).optional(),
});

View File

@@ -1,16 +0,0 @@
import { z } from 'zod'
export const createStudentSchema = z.object({
groupId: z.string().min(1),
fullName: z.string().min(1, 'El nombre completo es obligatorio'),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
guardianName: z.string().optional().or(z.literal('')),
guardianPhone: z.string().optional().or(z.literal('')),
notes: z.string().optional().or(z.literal('')),
})
export const updateStudentSchema = createStudentSchema.omit({ groupId: true }).partial()
export type CreateStudentInput = z.infer<typeof createStudentSchema>
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const StudentDtoSchema = z.object({
id: z.string(),
groupId: z.string(),
fullName: z.string(),
email: z.string().nullable(),
phone: z.string().nullable(),
guardianName: z.string().nullable(),
guardianPhone: z.string().nullable(),
notes: z.string().nullable(),
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type StudentDto = z.output<typeof StudentDtoSchema>;
export const StudentQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type StudentQuery = z.output<typeof StudentQuerySchema>;
export const StudentListSchema = z.object({
data: z.array(StudentDtoSchema),
pagination: paginationSchema,
});
export type StudentList = z.output<typeof StudentListSchema>;

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
const isoDateTimeSchema = z.string().datetime();
const pageSchema = createPageSchema();
const pageSizeSchema = createPageSizeSchema(10);
const paginationSchema = createPaginationSchema();
export const waitlistStatusSchema = z.enum(['PENDING', 'INVITED', 'JOINED', 'DECLINED']);
export const WaitlistEntryDtoSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
status: waitlistStatusSchema,
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type WaitlistEntryDto = z.output<typeof WaitlistEntryDtoSchema>;
export const WaitlistQuerySchema = z.object({
page: pageSchema,
pageSize: pageSizeSchema,
});
export type WaitlistQuery = z.output<typeof WaitlistQuerySchema>;
export const WaitlistListSchema = z.object({
data: z.array(WaitlistEntryDtoSchema),
pagination: paginationSchema,
});
export type WaitlistList = z.output<typeof WaitlistListSchema>;