Renamed Student to Attendee
This commit is contained in:
@@ -30,7 +30,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
|||||||
- `src/modules/<modulo>/` — por módulo: `index.ts`, `routes.ts` y `features/<accion>/{route,use-case}.ts`. **Patrón**: el route valida (`validate.query/json`) y delega en un use-case que devuelve `Result<T, ProblemDetails>` (`ok`/`err` desde `@gruperly/shared`); el route responde con `resultJson` (o `problemJson`).
|
- `src/modules/<modulo>/` — por módulo: `index.ts`, `routes.ts` y `features/<accion>/{route,use-case}.ts`. **Patrón**: el route valida (`validate.query/json`) y delega en un use-case que devuelve `Result<T, ProblemDetails>` (`ok`/`err` desde `@gruperly/shared`); el route responde con `resultJson` (o `problemJson`).
|
||||||
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
|
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
|
||||||
- `src/logger.ts` — pino + pino-pretty.
|
- `src/logger.ts` — pino + pino-pretty.
|
||||||
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `students`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
|
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
|
||||||
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`).
|
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`).
|
||||||
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
|
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
|
||||||
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
|
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Rename Student model (students table) to Attendee (attendees table)
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "students" RENAME TO "attendees";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "payments" RENAME COLUMN "studentId" TO "attendeeId";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "students_groupId_idx" RENAME TO "attendees_groupId_idx";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "payments_studentId_idx" RENAME TO "payments_attendeeId_idx";
|
||||||
|
|
||||||
|
-- RenameForeignKey
|
||||||
|
ALTER TABLE "payments" RENAME CONSTRAINT "payments_studentId_fkey" TO "payments_attendeeId_fkey";
|
||||||
@@ -14,10 +14,10 @@ model Group {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
|
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
members GroupMember[]
|
members GroupMember[]
|
||||||
students Student[]
|
attendees Attendee[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
|
||||||
@@map("groups")
|
@@map("groups")
|
||||||
}
|
}
|
||||||
@@ -57,42 +57,42 @@ enum Role {
|
|||||||
MEMBER
|
MEMBER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Student {
|
model Attendee {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
groupId String
|
groupId String
|
||||||
fullName String
|
fullName String
|
||||||
email String?
|
email String?
|
||||||
phone String?
|
phone String?
|
||||||
guardianName String?
|
guardianName String?
|
||||||
guardianPhone String?
|
guardianPhone String?
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@map("students")
|
@@map("attendees")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Payment {
|
model Payment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
groupId String
|
groupId String
|
||||||
studentId String
|
attendeeId String
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
currency String @default("MXN")
|
currency String @default("MXN")
|
||||||
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
|
status PaymentStatus @default(PENDING) // PENDING, PAID, OVERDUE, CANCELLED
|
||||||
dueDate DateTime
|
dueDate DateTime
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@index([studentId])
|
@@index([attendeeId])
|
||||||
@@map("payments")
|
@@map("payments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
|
|||||||
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
||||||
import { sessionAuthMiddleware } from '@/http/session-auth';
|
import { sessionAuthMiddleware } from '@/http/session-auth';
|
||||||
import { logger } from '@/logger';
|
import { logger } from '@/logger';
|
||||||
|
import { attendeesRoutes } from './modules/attendees';
|
||||||
import { authRoutes } from './modules/auth';
|
import { authRoutes } from './modules/auth';
|
||||||
import { groupsRoutes } from './modules/groups';
|
import { groupsRoutes } from './modules/groups';
|
||||||
import { healthCheckRoutes } from './modules/health-check';
|
import { healthCheckRoutes } from './modules/health-check';
|
||||||
import { onboardingRoutes } from './modules/onboarding';
|
import { onboardingRoutes } from './modules/onboarding';
|
||||||
import { paymentsRoutes } from './modules/payments';
|
import { paymentsRoutes } from './modules/payments';
|
||||||
import { studentsRoutes } from './modules/students';
|
|
||||||
import { waitlistRoutes } from './modules/waitlist';
|
import { waitlistRoutes } from './modules/waitlist';
|
||||||
|
|
||||||
const app = new Hono<BackendEnv>();
|
const app = new Hono<BackendEnv>();
|
||||||
@@ -35,7 +35,7 @@ api.route('/auth', authRoutes);
|
|||||||
api.route('/health', healthCheckRoutes);
|
api.route('/health', healthCheckRoutes);
|
||||||
api.route('/groups', groupsRoutes);
|
api.route('/groups', groupsRoutes);
|
||||||
api.route('/onboarding', onboardingRoutes);
|
api.route('/onboarding', onboardingRoutes);
|
||||||
api.route('/students', studentsRoutes);
|
api.route('/attendees', attendeesRoutes);
|
||||||
api.route('/payments', paymentsRoutes);
|
api.route('/payments', paymentsRoutes);
|
||||||
api.route('/waitlist', waitlistRoutes);
|
api.route('/waitlist', waitlistRoutes);
|
||||||
|
|
||||||
|
|||||||
18
apps/backend/src/modules/attendees/features/get-all/route.ts
Normal file
18
apps/backend/src/modules/attendees/features/get-all/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AttendeeQuery } from '@gruperly/shared';
|
||||||
|
import { AttendeeQuerySchema } from '@gruperly/shared';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { resultJson } from '@/http/problem-details';
|
||||||
|
import { validate } from '@/http/validate';
|
||||||
|
import { ListAttendees } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.get('/', validate.query(AttendeeQuerySchema), async (c) => {
|
||||||
|
const query = c.req.valid('query') as AttendeeQuery;
|
||||||
|
const useCase = new ListAttendees();
|
||||||
|
const result: Awaited<ReturnType<typeof useCase.execute>> = await useCase.execute(query);
|
||||||
|
|
||||||
|
return resultJson(c, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { PrismaClient } from '@generated/prisma/client';
|
import type { PrismaClient } from '@generated/prisma/client';
|
||||||
import type { ProblemDetails, Result, StudentDto, StudentList, StudentQuery } from '@gruperly/shared';
|
import type { AttendeeDto, AttendeeList, AttendeeQuery, ProblemDetails, Result } from '@gruperly/shared';
|
||||||
import { ok } from '@gruperly/shared';
|
import { ok } from '@gruperly/shared';
|
||||||
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
type ListStudentsDeps = {
|
type ListAttendeesDeps = {
|
||||||
db?: Pick<PrismaClient, 'student'>;
|
db?: Pick<PrismaClient, 'attendee'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StudentRecord = {
|
type AttendeeRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@@ -21,7 +21,7 @@ type StudentRecord = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toStudentDto(record: StudentRecord): StudentDto {
|
function toAttendeeDto(record: AttendeeRecord): AttendeeDto {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
groupId: record.groupId,
|
groupId: record.groupId,
|
||||||
@@ -36,22 +36,22 @@ function toStudentDto(record: StudentRecord): StudentDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListStudents {
|
export class ListAttendees {
|
||||||
constructor(private readonly deps: ListStudentsDeps = {}) {}
|
constructor(private readonly deps: ListAttendeesDeps = {}) {}
|
||||||
|
|
||||||
async execute(query: StudentQuery): Promise<Result<StudentList, ProblemDetails>> {
|
async execute(query: AttendeeQuery): Promise<Result<AttendeeList, ProblemDetails>> {
|
||||||
const db = this.deps.db ?? prisma;
|
const db = this.deps.db ?? prisma;
|
||||||
const [records, total] = await Promise.all([
|
const [records, total] = await Promise.all([
|
||||||
db.student.findMany({
|
db.attendee.findMany({
|
||||||
skip: getPaginationOffset(query),
|
skip: getPaginationOffset(query),
|
||||||
take: query.pageSize,
|
take: query.pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
db.student.count(),
|
db.attendee.count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
data: records.map(toStudentDto),
|
data: records.map(toAttendeeDto),
|
||||||
pagination: getPaginationMetadata(query, total),
|
pagination: getPaginationMetadata(query, total),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
1
apps/backend/src/modules/attendees/index.ts
Normal file
1
apps/backend/src/modules/attendees/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as attendeesRoutes } from './routes';
|
||||||
@@ -11,7 +11,7 @@ type ListPaymentsDeps = {
|
|||||||
type PaymentRecord = {
|
type PaymentRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
studentId: string;
|
attendeeId: string;
|
||||||
amount: { toString(): string };
|
amount: { toString(): string };
|
||||||
currency: string;
|
currency: string;
|
||||||
status: PaymentDto['status'];
|
status: PaymentDto['status'];
|
||||||
@@ -25,7 +25,7 @@ function toPaymentDto(record: PaymentRecord): PaymentDto {
|
|||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
groupId: record.groupId,
|
groupId: record.groupId,
|
||||||
studentId: record.studentId,
|
attendeeId: record.attendeeId,
|
||||||
amount: Number(record.amount.toString()),
|
amount: Number(record.amount.toString()),
|
||||||
currency: record.currency,
|
currency: record.currency,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { StudentQuery } from '@gruperly/shared';
|
|
||||||
import { StudentQuerySchema } from '@gruperly/shared';
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { resultJson } from '@/http/problem-details';
|
|
||||||
import { validate } from '@/http/validate';
|
|
||||||
import { ListStudents } from './use-case';
|
|
||||||
|
|
||||||
const route = new Hono();
|
|
||||||
|
|
||||||
route.get('/', validate.query(StudentQuerySchema), async (c) => {
|
|
||||||
const query = c.req.valid('query') as StudentQuery;
|
|
||||||
const useCase = new ListStudents();
|
|
||||||
const result: Awaited<ReturnType<typeof useCase.execute>> = await useCase.execute(query);
|
|
||||||
|
|
||||||
return resultJson(c, result);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default route;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as studentsRoutes } from './routes';
|
|
||||||
@@ -3,7 +3,7 @@ import { Hono } from 'hono';
|
|||||||
|
|
||||||
mock.module('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
default: {
|
default: {
|
||||||
student: {
|
attendee: {
|
||||||
findMany: mock(),
|
findMany: mock(),
|
||||||
count: mock(),
|
count: mock(),
|
||||||
},
|
},
|
||||||
@@ -13,13 +13,13 @@ mock.module('@/lib/prisma', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { studentsRoutes } from '@/modules/students';
|
import { attendeesRoutes } from '@/modules/attendees';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.route('/students', studentsRoutes);
|
app.route('/attendees', attendeesRoutes);
|
||||||
|
|
||||||
const student = {
|
const attendee = {
|
||||||
id: 'student-1',
|
id: 'attendee-1',
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
fullName: 'Ana Pérez',
|
fullName: 'Ana Pérez',
|
||||||
email: 'ana@example.com',
|
email: 'ana@example.com',
|
||||||
@@ -31,41 +31,41 @@ const student = {
|
|||||||
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
|
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('students routes', () => {
|
describe('attendees routes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists students with pagination', async () => {
|
it('lists attendees with pagination', async () => {
|
||||||
prisma.student.findMany.mockResolvedValue([student]);
|
prisma.attendee.findMany.mockResolvedValue([attendee]);
|
||||||
prisma.student.count.mockResolvedValue(21);
|
prisma.attendee.count.mockResolvedValue(21);
|
||||||
|
|
||||||
const res = await app.request('/students?page=2&pageSize=10');
|
const res = await app.request('/attendees?page=2&pageSize=10');
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
...student,
|
...attendee,
|
||||||
createdAt: student.createdAt.toISOString(),
|
createdAt: attendee.createdAt.toISOString(),
|
||||||
updatedAt: student.updatedAt.toISOString(),
|
updatedAt: attendee.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 },
|
pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 },
|
||||||
});
|
});
|
||||||
expect(prisma.student.findMany).toHaveBeenCalledWith({
|
expect(prisma.attendee.findMany).toHaveBeenCalledWith({
|
||||||
skip: 10,
|
skip: 10,
|
||||||
take: 10,
|
take: 10,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
expect(prisma.student.count).toHaveBeenCalledWith();
|
expect(prisma.attendee.count).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses default pagination', async () => {
|
it('uses default pagination', async () => {
|
||||||
prisma.student.findMany.mockResolvedValue([]);
|
prisma.attendee.findMany.mockResolvedValue([]);
|
||||||
prisma.student.count.mockResolvedValue(0);
|
prisma.attendee.count.mockResolvedValue(0);
|
||||||
|
|
||||||
const res = await app.request('/students');
|
const res = await app.request('/attendees');
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
@@ -75,7 +75,7 @@ describe('students routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid pagination query parameters', async () => {
|
it('rejects invalid pagination query parameters', async () => {
|
||||||
const res = await app.request('/students?page=0&pageSize=25');
|
const res = await app.request('/attendees?page=0&pageSize=25');
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
|
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
|
||||||
@@ -20,8 +20,8 @@ app.route('/payments', paymentsRoutes);
|
|||||||
|
|
||||||
const payment = {
|
const payment = {
|
||||||
id: 'payment-1',
|
id: 'payment-1',
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
studentId: 'student-1',
|
attendeeId: 'attendee-1',
|
||||||
amount: { toString: () => '150.50' } as { toString(): string },
|
amount: { toString: () => '150.50' } as { toString(): string },
|
||||||
currency: 'MXN',
|
currency: 'MXN',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -47,8 +47,8 @@ describe('payments routes', () => {
|
|||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 'payment-1',
|
id: 'payment-1',
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
studentId: 'student-1',
|
attendeeId: 'attendee-1',
|
||||||
amount: 150.5,
|
amount: 150.5,
|
||||||
currency: 'MXN',
|
currency: 'MXN',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe('session auth', () => {
|
|||||||
expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true);
|
expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true);
|
||||||
expect(isPublicApiRequest('POST', '/api/v1/auth/sign-in/email')).toBe(true);
|
expect(isPublicApiRequest('POST', '/api/v1/auth/sign-in/email')).toBe(true);
|
||||||
expect(isPublicApiRequest('GET', '/api/v1/groups')).toBe(false);
|
expect(isPublicApiRequest('GET', '/api/v1/groups')).toBe(false);
|
||||||
expect(isPublicApiRequest('GET', '/api/v1/students')).toBe(false);
|
expect(isPublicApiRequest('GET', '/api/v1/attendees')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows public requests without a session', async () => {
|
it('allows public requests without a session', async () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function ConfirmationStep({ group, providerName }: ConfirmationStepProps)
|
|||||||
</span>
|
</span>
|
||||||
<h2 className="text-2xl font-bold text-primary">¡Todo listo!</h2>
|
<h2 className="text-2xl font-bold text-primary">¡Todo listo!</h2>
|
||||||
<p className="text-sm text-foreground/60">
|
<p className="text-sm text-foreground/60">
|
||||||
Tu grupo se creó y ya podés empezar a cobrar a tus alumnos.
|
Tu grupo se creó y ya podés empezar a cobrar a tus miembros.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function ConfirmationStep({ group, providerName }: ConfirmationStepProps)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2.5">
|
<div className="flex items-center justify-between py-2.5">
|
||||||
<dt className="text-foreground/60">Cupo</dt>
|
<dt className="text-foreground/60">Cupo</dt>
|
||||||
<dd className="font-semibold text-primary">{group.capacity ?? '—'} alumnos</dd>
|
<dd className="font-semibold text-primary">{group.capacity ?? '—'} miembros</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2.5">
|
<div className="flex items-center justify-between py-2.5">
|
||||||
<dt className="text-foreground/60">Vencimiento</dt>
|
<dt className="text-foreground/60">Vencimiento</dt>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepP
|
|||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h2 className="text-2xl font-bold text-primary">Elegí tu procesador de cobro</h2>
|
<h2 className="text-2xl font-bold text-primary">Elegí tu procesador de cobro</h2>
|
||||||
<p className="text-sm text-foreground/60">
|
<p className="text-sm text-foreground/60">
|
||||||
Los pagos de tus alumnos van a llegar por esta plataforma.
|
Los pagos de tus miembros van a llegar por esta plataforma.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const STEPS_TO_SETUP = [
|
|||||||
{
|
{
|
||||||
icon: Rocket,
|
icon: Rocket,
|
||||||
title: 'Empezá a cobrar',
|
title: 'Empezá a cobrar',
|
||||||
description: 'Todo listo para sumar alumnos y cobrar al instante.',
|
description: 'Todo listo para sumar miembros y cobrar al instante.',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function GroupCard({ group }: { group: GroupDto }) {
|
|||||||
<div className="flex items-center gap-2 text-foreground/70">
|
<div className="flex items-center gap-2 text-foreground/70">
|
||||||
<Users className="size-4 shrink-0 text-accent" />
|
<Users className="size-4 shrink-0 text-accent" />
|
||||||
<span>
|
<span>
|
||||||
Cupo {group.capacity ?? '—'} alumnos
|
Cupo {group.capacity ?? '—'} miembros
|
||||||
{group.price != null
|
{group.price != null
|
||||||
? ` · ${formatPrice(group.price)}${group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}`
|
? ` · ${formatPrice(group.price)}${group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './lib/problem-details.js';
|
export * from './lib/problem-details.js';
|
||||||
export * from './lib/result.js';
|
export * from './lib/result.js';
|
||||||
|
export * from './schemas/attendees.js';
|
||||||
export * from './schemas/enums.js';
|
export * from './schemas/enums.js';
|
||||||
export * from './schemas/groups.js';
|
export * from './schemas/groups.js';
|
||||||
export * from './schemas/health-check.js';
|
export * from './schemas/health-check.js';
|
||||||
@@ -7,5 +8,4 @@ export * from './schemas/onboarding.js';
|
|||||||
export * from './schemas/pagination.js';
|
export * from './schemas/pagination.js';
|
||||||
export * from './schemas/payments.js';
|
export * from './schemas/payments.js';
|
||||||
export * from './schemas/problem-details.js';
|
export * from './schemas/problem-details.js';
|
||||||
export * from './schemas/students.js';
|
|
||||||
export * from './schemas/waitlist.js';
|
export * from './schemas/waitlist.js';
|
||||||
@@ -6,7 +6,7 @@ const pageSchema = createPageSchema();
|
|||||||
const pageSizeSchema = createPageSizeSchema(10);
|
const pageSizeSchema = createPageSizeSchema(10);
|
||||||
const paginationSchema = createPaginationSchema();
|
const paginationSchema = createPaginationSchema();
|
||||||
|
|
||||||
export const StudentDtoSchema = z.object({
|
export const AttendeeDtoSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
groupId: z.string(),
|
groupId: z.string(),
|
||||||
fullName: z.string(),
|
fullName: z.string(),
|
||||||
@@ -18,16 +18,16 @@ export const StudentDtoSchema = z.object({
|
|||||||
createdAt: isoDateTimeSchema,
|
createdAt: isoDateTimeSchema,
|
||||||
updatedAt: isoDateTimeSchema,
|
updatedAt: isoDateTimeSchema,
|
||||||
});
|
});
|
||||||
export type StudentDto = z.output<typeof StudentDtoSchema>;
|
export type AttendeeDto = z.output<typeof AttendeeDtoSchema>;
|
||||||
|
|
||||||
export const StudentQuerySchema = z.object({
|
export const AttendeeQuerySchema = z.object({
|
||||||
page: pageSchema,
|
page: pageSchema,
|
||||||
pageSize: pageSizeSchema,
|
pageSize: pageSizeSchema,
|
||||||
});
|
});
|
||||||
export type StudentQuery = z.output<typeof StudentQuerySchema>;
|
export type AttendeeQuery = z.output<typeof AttendeeQuerySchema>;
|
||||||
|
|
||||||
export const StudentListSchema = z.object({
|
export const AttendeeListSchema = z.object({
|
||||||
data: z.array(StudentDtoSchema),
|
data: z.array(AttendeeDtoSchema),
|
||||||
pagination: paginationSchema,
|
pagination: paginationSchema,
|
||||||
});
|
});
|
||||||
export type StudentList = z.output<typeof StudentListSchema>;
|
export type AttendeeList = z.output<typeof AttendeeListSchema>;
|
||||||
@@ -11,7 +11,7 @@ export const paymentStatusSchema = z.enum(['PENDING', 'PAID', 'OVERDUE', 'CANCEL
|
|||||||
export const PaymentDtoSchema = z.object({
|
export const PaymentDtoSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
groupId: z.string(),
|
groupId: z.string(),
|
||||||
studentId: z.string(),
|
attendeeId: z.string(),
|
||||||
amount: z.coerce.number().positive(),
|
amount: z.coerce.number().positive(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
status: paymentStatusSchema,
|
status: paymentStatusSchema,
|
||||||
|
|||||||
4
stack.md
4
stack.md
@@ -41,7 +41,7 @@ gruperly/
|
|||||||
│ │ ├── src/
|
│ │ ├── src/
|
||||||
│ │ │ ├── http/ # Infraestructura HTTP (validate, problem-details, session-auth, ...)
|
│ │ │ ├── http/ # Infraestructura HTTP (validate, problem-details, session-auth, ...)
|
||||||
│ │ │ ├── lib/ # Prisma (cliente + UnitOfWork), pagination, email, helpers
|
│ │ │ ├── lib/ # Prisma (cliente + UnitOfWork), pagination, email, helpers
|
||||||
│ │ │ ├── modules/ # Módulos: health-check, auth, groups, students, payments, waitlist
|
│ │ │ ├── modules/ # Módulos: health-check, auth, groups, attendees, payments, waitlist
|
||||||
│ │ │ │ └── <módulo>/
|
│ │ │ │ └── <módulo>/
|
||||||
│ │ │ │ ├── routes.ts
|
│ │ │ │ ├── routes.ts
|
||||||
│ │ │ │ └── features/<accion>/{route,use-case}.ts
|
│ │ │ │ └── features/<accion>/{route,use-case}.ts
|
||||||
@@ -73,7 +73,7 @@ gruperly/
|
|||||||
│ │ ├── src/
|
│ │ ├── src/
|
||||||
│ │ │ ├── schemas/ # Validaciones Zod compartidas
|
│ │ │ ├── schemas/ # Validaciones Zod compartidas
|
||||||
│ │ │ │ ├── group.schema.ts
|
│ │ │ │ ├── group.schema.ts
|
||||||
│ │ │ │ ├── student.schema.ts
|
│ │ │ │ ├── attendee.schema.ts
|
||||||
│ │ │ │ └── payment.schema.ts
|
│ │ │ │ └── payment.schema.ts
|
||||||
│ │ │ └── index.ts
|
│ │ │ └── index.ts
|
||||||
│ │ ├── tsconfig.json
|
│ │ ├── tsconfig.json
|
||||||
|
|||||||
Reference in New Issue
Block a user