diff --git a/README.md b/README.md index c997880..8434599 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,494 @@ -

- Nest Logo -

+# ExDev API -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest +API de ExDev desarrollada con NestJS 10, TypeScript y PostgreSQL mediante `pg`. Sirve contenido público para la web y recibe postulaciones al club. -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Coverage -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- +Este README describe los endpoints implementados en el código local al **25 de septiembre de 2026**, no funcionalidades futuras ni el estado de un despliegue particular. -## Description +## Índice -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +- [Configuración y ejecución](#configuración-y-ejecución) +- [Convenciones y seguridad](#convenciones-y-seguridad) +- [Endpoints disponibles](#endpoints-disponibles) +- [POST /applications](#post-applications) +- [GET /members](#get-members) +- [POST /members](#post-members) +- [GET /projects](#get-projects) +- [POST /projects](#post-projects) +- [GET /events](#get-events) +- [Errores](#errores) +- [Estructura y pruebas](#estructura-y-pruebas) +- [Base de datos y pendientes](#base-de-datos-y-pendientes) -## Project setup +## Configuración y ejecución -```bash -$ npm install +Se necesita Node.js/npm, acceso a PostgreSQL y el esquema actualizado de ExDev. No hay ORM ni migraciones automáticas al arrancar. + +```sh +npm install +``` + +Crear `.env` en la raíz del proyecto y configurar los valores del ambiente mediante el canal seguro definido por el equipo. Este README documenta únicamente los nombres y el propósito de las variables; no incluye un bloque de configuración para copiar. + +| Variable | Uso | +|---|---| +| `DATABASE_URL` | Conexión de `pg` a la base de datos. | +| `PORT` | Puerto HTTP; el código usa 3000 si se omite. Localmente usar 3001 si el frontend ocupa 3000. | +| `RUT_ENCRYPTION_KEY_VERSION` | Entero positivo que selecciona la versión de la clave de cifrado. | +| `RUT_ENCRYPTION_KEY_V1` | Para versión 1: clave aleatoria de **32 bytes codificados en Base64**. No una contraseña elegida manualmente. Para versión 2 se usa `RUT_ENCRYPTION_KEY_V2`, etc. | + + +```sh +# Desarrollo con recarga +npm run start:dev + +# Compilar y ejecutar la versión compilada +npm run build +npm run start:prod +``` + +Con `PORT=3001`, la base local es `http://localhost:3001`. Las rutas no llevan prefijo `/api` en NestJS. Cualquier prefijo externo dependerá del proxy del despliegue. + +El `docker-compose.yml` usa `/opt/exdev-api/.env` y publica `3001:3000`: dentro del contenedor la API debe escuchar en 3000, no copiar sin revisar el PORT del ejemplo local. El compose consume una imagen; no compila los cambios locales. + +## Convenciones y seguridad + +- POST: enviar `Content-Type: application/json`. GET: parámetros en la URL, sin body. +- GET exitoso: HTTP **200**. POST exitoso: HTTP **201**. Las listas vacías devuelven 200 con `data: []`. +- `/applications` usa nombres **snake_case**. Los POST `/members` y `/projects` usan **camelCase**. Las respuestas GET conservan nombres del esquema SQL. +- Los IDs `bigint` leídos directamente por `pg` se devuelven como strings. En `projects.miembros`, los IDs se construyen dentro de JSON de PostgreSQL y actualmente llegan como números. No asumir un contrato numérico uniforme ni convertir strings grandes a `Number`. +- Campos opcionales de texto suelen admitir omisión o NULL; el servicio elimina espacios exteriores. No enviar cadenas vacías para campos obligatorios. Los límites de texto de aplicaciones se validan con `.length` de JavaScript; emojis pueden contar como dos unidades. +- El código actual no instala un ValidationPipe global ni rechaza sistemáticamente propiedades extra. Enviar solo los campos documentados. +- CORS local permite `http://localhost:3000` y `http://127.0.0.1:3000`. Antes de usar otro frontend, configurar explícitamente su origen. **CORS no es autenticación.** + +**Pendiente de seguridad antes de exponer administración:** los POST `/members` y `/projects` existen, pero no tienen guards IAM en el código actual. Deben protegerse o bloquearse en el despliegue; no tratarlos como endpoints públicos autorizados. No se implementó autenticación, autorización por rol ni limitación de solicitudes en estos controladores. + +## Endpoints disponibles + +| Método | Ruta | Función | Acceso previsto | +|---|---|---|---| +| POST | `/applications` | Registrar una postulación en el período habilitado. | Formulario público. | +| GET | `/applications` | Listar postulaciones sin RUT ni metadatos de cifrado. | Administración; actualmente sin IAM. | +| GET | `/members` | Listar perfiles públicos activos. | Público. | +| POST | `/members` | Crear miembro con roles y especialidades. | Administración; IAM pendiente. | +| GET | `/projects` | Listar proyectos publicados y participantes públicos. | Público. | +| POST | `/projects` | Crear proyecto y sus asociaciones de miembros. | Administración; IAM pendiente. | +| GET | `/events` | Consultar agenda publicada. | Público. | + +No hay GET por ID, PUT/PATCH/DELETE, descifrado de RUT ni endpoints de votos, períodos, patrocinadores o catálogos implementados actualmente. Que exista una tabla no implica que exista una ruta. + +## GET /applications + +Lista todas las postulaciones, ordenadas por created_at descendente y luego ID descendente. No recibe body ni filtros; aún no tiene paginación. Responde 200 con `{ "totalPostulaciones": 0, "postulaciones": [] }` cuando no hay registros. + +Cada objeto incluye: id, periodo_id, nombre_completo, edad, correo_institucional, campus, carrera, anio_ingreso, anio_actual, area_interes1, area_interes2, area_interes3, ayudantias, horas_disponibles_semanales, motivo_postulacion, proyecto_idea, portafolio, postulacion_conjunta, pitch, apodo, estado_postulacion, resuelta_en, resuelta_por, created_at y updated_at. + +**No selecciona ni devuelve rut, rut_cifrado o rut_clave_version, y no descifra datos.** El total se calcula con las filas de la misma consulta. Si falla, devuelve 500 con responseCode E003 y un mensaje genérico. + +**Acceso administrativo aún sin protección IAM:** sigue exponiendo nombres, correos y respuestas personales. Excluir el RUT no vuelve anónima la respuesta; restringir su acceso antes de exponerlo públicamente. + +## POST /applications + +### Qué hace + +1. Valida los datos y el dígito verificador del RUT; normaliza RUT y correo. +2. Cifra el RUT en la API con AES-256-GCM. La BD recibe bytes cifrados, no el RUT en texto plano. +3. Abre una transacción, selecciona y bloquea el período `habilitado` y comprueba que `fecha_apertura <= ahora < fecha_cierre`. +4. Inserta la postulación con ese período; usa el estado por defecto `pendiente`. Confirma todo o revierte ante un error. + +Debe existir un período habilitado dentro de fechas. No se crea automáticamente y no lo selecciona el navegador. No enviar `periodo_id`, `estado_postulacion`, `resuelta_por`, `rut_cifrado` ni `rut_clave_version`: los valores enviados para esos campos no se usan en este POST. + +### Body + +| Campo | Tipo JSON | Obligatorio | Límites / significado | +|---|---|---|---| +| `nombre_completo` | string | Sí | No vacío; máximo 200. | +| `rut` | string | Sí | Máximo 20 en entrada. Admite puntos; formato normalizado de 1–8 dígitos, guion y dígito/K. Valida módulo 11; no acepta cuerpo cero. | +| `edad` | number entero | No | 16–99. | +| `correo_institucional` | string | Sí | Máximo 150; dominio @utem.cl. Se recorta y convierte a minúsculas. | +| `campus` | string | No | Máximo 50. | +| `carrera` | string | Sí | No vacío; máximo 100. | +| `anio_ingreso` | number entero | No | Año actual de Chile y 10 anteriores, inclusive. En 2026: 2016–2026. | +| `anio_actual` | number entero | No | 1–2.147.483.647; año que cursa. | +| `area_interes1` | string | Sí | No vacío; máximo 80. | +| `area_interes2` | string | No | Máximo 80. | +| `area_interes3` | string | No | Máximo 80. | +| `ayudantias` | string | No | Máximo 1000. | +| `horas_disponibles_semanales` | number entero | No | 0–2.147.483.647; máximo técnico, no recomendación de negocio. | +| `motivo_postulacion` | string | No | Máximo 5000. | +| `proyecto_idea` | string | No | Máximo 5000. | +| `portafolio` | string | No | Máximo 500; no valida que sea URL. | +| `postulacion_conjunta` | string | No | Máximo 500; nombres/referencias, no asociación automática. | +| `pitch` | string | No | Máximo 10000. | +| `apodo` | string | No | Máximo 100. | + +Los opcionales aceptan omisión, NULL o texto vacío, que se guarda como NULL. Los enteros también aceptan cadenas de dígitos, aunque se recomienda enviar números JSON. El frontend puede exigir más campos que este contrato mínimo de API. No existe género en este body. + +Ejemplo ficticio, solo para staging; actualizar anio_ingreso cuando corresponda: + +```json +{ + "nombre_completo": "Persona de prueba", + "rut": "12.345.678-5", + "edad": 20, + "correo_institucional": "prueba@utem.cl", + "campus": "Macul", + "carrera": "Ingeniería Civil en Computación Mención Informática", + "anio_ingreso": 2026, + "anio_actual": 1, + "area_interes1": "Backend", + "area_interes2": "Robótica", + "area_interes3": null, + "ayudantias": null, + "horas_disponibles_semanales": 4, + "motivo_postulacion": "Quiero aprender desarrollando proyectos en equipo.", + "proyecto_idea": "Una aplicación para organizar actividades del club.", + "portafolio": "https://example.com/portfolio", + "postulacion_conjunta": null, + "pitch": "Me interesa colaborar en proyectos web.", + "apodo": null +} +``` + +Respuesta **201**: + +```json +{ + "responseCode": "I001", + "message": "La postulacion ha sido realizada con exito", + "idPostulacion": "1" +} +``` + +### Reglas y cifrado + +- El correo es único normalizado **por período**. Repetirlo dentro del mismo período devuelve 409; puede volver a postular en otro. +- No hay deduplicación por RUT en el esquema cifrado actual. +- El cifrado usa un nonce aleatorio de 12 bytes, tag de 16 bytes y ciphertext, concatenados en `rut_cifrado`. AAD: `exdev:postulaciones:rut:v`. `rut_clave_version` indica la clave necesaria para descifrar, no contiene el secreto. +- No se devuelve el RUT, la clave, ciphertext ni detalles de PostgreSQL. No registrar cuerpos del formulario en logs/proxies; usar HTTPS fuera del desarrollo local. +- El cifrado protege el RUT almacenado; no cifra automáticamente nombre, correo ni edad. + +## GET /members + +```http +GET /members?limit=4 +``` + +| Query | Default | Valores | +|---|---|---| +| `limit` | 4 | Entero de 1 a 100. | + +Devuelve miembros con `estado = activo` y `perfil_publico = true`, ordenados por nombre. Solo incluye roles con asignación y catálogo activos, y especialidades activas. No devuelve correo, iam_subject ni información de postulaciones. + +`activo` significa que sigue perteneciendo al club, no que participa con frecuencia. Un Titulado que mantiene su vínculo puede estar activo. + +Respuesta **200**: + +```json +{ + "data": [ + { + "id": "1", + "nombre": "Persona de prueba", + "carrera": "Ingeniería Civil en Computación Mención Informática", + "anio_ingreso_carrera": 2026, + "foto_publica": false, + "roles": ["Miembro activo"], + "especialidades": ["Backend", "Frontend"] + } + ] +} +``` + +roles y especialidades son arreglos de nombres, no objetos con ID. Pueden estar vacíos. anio_ingreso_carrera puede ser NULL en la BD. foto_publica es una autorización: no hay URL de foto ni storage en esta respuesta. No hay offset, total ni hasMore para miembros. + +## POST /members + +**Endpoint administrativo todavía sin IAM.** Crea el miembro, sus asignaciones de roles activas y sus especialidades en una sola transacción. Si falla una relación, revierte todo. + +### Body + +| Campo | Tipo JSON | Obligatorio | Regla / default | +|---|---|---|---| +| `nombre` | string | Sí | No vacío; se recorta. | +| `correoInstitucional` | string | Sí | @utem.cl; se recorta y convierte a minúsculas; único normalizado. | +| `carrera` | string | Sí | No vacía; se recorta. | +| `anioIngresoCarrera` | number entero | Sí | 1900–2100. Este rango no es el del formulario de postulaciones. | +| `iamSubject` | string o null | No | Identidad IAM; no vacío si se proporciona. Default NULL. | +| `estado` | string | No | `activo` o `inactivo`; default activo. | +| `perfilPublico` | boolean | No | Default false. | +| `fotoPublica` | boolean | No | Default false; true exige perfilPublico = true. | +| `roleIds` | array de números enteros | No | IDs existentes de roles, positivos y sin duplicados. Default []; NULL no está admitido. | +| `specialtyIds` | array de números enteros | No | IDs existentes de especialidades, positivos y sin duplicados. Default []; NULL no está admitido. | + +Estos textos son `text`, sin límite propio de longitud en el servicio/esquema. El servicio convierte IDs con Number y exige enteros seguros (hasta 9.007.199.254.740.991); preferir números JSON. Hoy comprueba existencia mediante FK, pero no impide asignar un catálogo inactivo. No se crean nuevos roles o especialidades con este POST. + +Ejemplo: los IDs 1 y 2 son ilustrativos; verificar los catálogos de la base antes de usarlos. + +```json +{ + "nombre": "Persona de prueba", + "correoInstitucional": "prueba@utem.cl", + "carrera": "Ingeniería Civil en Computación Mención Informática", + "anioIngresoCarrera": 2026, + "iamSubject": null, + "estado": "activo", + "perfilPublico": true, + "fotoPublica": false, + "roleIds": [1], + "specialtyIds": [1, 2] +} +``` + +Respuesta **201**: + +```json +{ "message": "Miembro creado correctamente", "id": "1" } +``` + +No crea una cuenta IAM ni convierte una postulación en aceptada. Aunque algunas columnas sean nullable en SQL, correoInstitucional y anioIngresoCarrera son obligatorios en este endpoint. + +## GET /projects + +```http +GET /projects?limit=4&featured=true +``` + +| Query | Default | Valores | +|---|---|---| +| `limit` | 100 | Entero de 1 a 100. | +| `featured` | Sin filtro | `true`: solo destacados; `false`: solo no destacados; omitir: ambos. | + +Solo devuelve proyectos publicados, de cualquier estado. Ordena por orden ascendente y created_at descendente. Los participantes incluidos deben ser miembros activos con perfil público; se ordenan por nombre. No hay offset, total ni hasMore. + +Respuesta **200**: + +```json +{ + "data": [ + { + "id": "1", + "nombre": "exdev_website", + "descripcion_breve": "Página web de ExDev.", + "descripcion": "Sitio público para presentar el club y sus actividades.", + "estado": "activo", + "fecha_inicio": null, + "fecha_fin": null, + "destacado": true, + "miembros": [ + { "id": 1, "nombre": "Persona de prueba", "funcion": "Desarrollo web" } + ] + } + ] +} ``` -## Compile and run the project +Las descripciones, fechas y funcion pueden ser NULL; miembros puede ser []. Las fechas no nulas se seleccionan directamente como SQL date: con los parsers por defecto de pg se serializan como timestamps ISO y pueden depender de la zona horaria del proceso. **A diferencia de `/events`, todavía no se normalizan explícitamente a YYYY-MM-DD en este GET.** + +## POST /projects + +**Endpoint administrativo todavía sin IAM.** Crea un proyecto y sus asociaciones en proyecto_miembros dentro de una transacción. No crea los perfiles de los participantes. + +### Body + +| Campo | Tipo JSON | Obligatorio | Regla / default | +|---|---|---|---| +| `nombre` | string | Sí | No vacío; sin unicidad del nombre en BD. | +| `descripcionBreve` | string o null | No | Resumen; default NULL. | +| `descripcion` | string o null | No | Detalle; default NULL. | +| `estado` | string | No | Default planificacion; valores debajo. | +| `fechaInicio` | string o null | No | YYYY-MM-DD; default NULL. | +| `fechaFin` | string o null | No | YYYY-MM-DD; no anterior al inicio si ambas existen. | +| `publicado` | boolean | No | Default false. | +| `destacado` | boolean | No | Default false; true exige publicado = true. | +| `orden` | number entero | No | Default 0; menor primero. La BD admite el rango integer de 32 bits, incluidos negativos. | +| `members` | array de objetos | No | Default []; no repetir miembro. | + +Todos los textos son `text`, sin máximo específico de longitud. Los textos opcionales vacíos se guardan como NULL. Las fechas se validan primero por formato y después por PostgreSQL; enviar fechas reales, no solo cadenas con apariencia de fecha. + +| Estado | Significado | +|---|---| +| `planificacion` | Definición de objetivos, tareas y recursos. | +| `activo` | En ejecución o funcionamiento continuo. | +| `bloqueado` | No puede avanzar por una dependencia o recurso. | +| `pausado` | Detenido temporalmente. | +| `completado` | Objetivo alcanzado y proyecto cerrado. | +| `cancelado` | Cerrado sin completar y sin continuidad prevista. | -```bash -# development -$ npm run start +Cada objeto de `members`: -# watch mode -$ npm run start:dev +| Campo | Tipo JSON | Obligatorio | Regla | +|---|---|---|---| +| `memberId` | number entero | Sí | ID existente, positivo y seguro en JavaScript. | +| `functionName` | string o null | No | Función dentro del proyecto; default NULL. | +| `startDate` | string o null | No | YYYY-MM-DD; default NULL. | +| `endDate` | string o null | No | YYYY-MM-DD; no anterior a startDate si ambas existen. | -# production mode -$ npm run start:prod +Ejemplo (memberId debe existir): + +```json +{ + "nombre": "exdev_website", + "descripcionBreve": "Página web de ExDev.", + "descripcion": "Sitio público para presentar proyectos, miembros y actividades del club.", + "estado": "activo", + "fechaInicio": "2026-09-01", + "fechaFin": null, + "publicado": true, + "destacado": true, + "orden": 1, + "members": [ + { + "memberId": 1, + "functionName": "Desarrollo web", + "startDate": "2026-09-01", + "endDate": null + } + ] +} ``` -## Run tests +Respuesta **201**: -```bash -# unit tests -$ npm run test +```json +{ "message": "Proyecto creado correctamente", "id": "1" } +``` -# e2e tests -$ npm run test:e2e +## GET /events -# test coverage -$ npm run test:cov +```http +GET /events?limit=4&upcoming=true&offset=0 ``` -## Resources +| Query | Default | Valores | +|---|---|---| +| `limit` | 20 | Entero de 1 a 100. | +| `upcoming` | `true` | `true`: solo próximos/en curso programados; `false`: toda la agenda publicada. | +| `offset` | 0 | Entero de 0 a 1.000.000; cantidad de filas que se omiten. | + +Siempre filtra publicado = true. Con upcoming=true, exige estado programado y que `fecha_fin` (o fecha_inicio si no tiene fin) sea hoy o posterior, según America/Santiago. Los eventos de varios días siguen apareciendo hasta su último día. -Check out a few resources that may come in handy when working with NestJS: +Orden: primero eventos cuyo último día no ha pasado, por inicio ascendente; después los pasados, por inicio descendente; ID desempata. Con upcoming=false también pueden aparecer cancelados y finalizados publicados. + +Respuesta **200** (evento de ejemplo): + +```json +{ + "data": [ + { + "id": "1", + "tipo_evento": "feria", + "titulo_evento": "Feria Vive la Investigación", + "descripcion": "Exhibición de proyectos y actividades del club · sin inscripción.", + "fecha_inicio": "2026-10-14", + "fecha_fin": "2026-10-15", + "fecha_texto": "14 y 15 OCT", + "ubicacion": "UTEM — campus Macul", + "url_evento": null, + "tipo_accion": "acceso_libre", + "url_accion": null, + "estado": "programado" + } + ], + "hasMore": false +} +``` + +Las fechas se devuelven explícitamente como YYYY-MM-DD, sin conversión a timestamps. Si hasMore=true, pedir la siguiente página con offset + limit. No se devuelve total. + +| tipo_accion | Uso en frontend | url_accion | +|---|---|---| +| `postulacion` | Botón “Postularse”. | Obligatoria; `/apply` para el club o enlace externo. | +| `inscripcion` | Botón “Inscribirse”. | Obligatoria. | +| `acceso_libre` | Texto “Abierto a todos”. | NULL. | +| NULL | Sin acción. | NULL. | + +tipo_evento clasifica la actividad (feria, charla, taller, curso, etc.); no decide el botón. estado admite programado/cancelado/finalizado. El frontend oculta acciones de eventos pasados/cancelados y valida los enlaces; el GET entrega los datos guardados, no modifica estados ni abre períodos de postulación. + +La lista actual muestra título, descripción y etiqueta de fecha. ubicacion y url_evento se conservan para un futuro detalle, pero no se muestran en esa lista. Si fecha_texto es NULL, el frontend genera la etiqueta desde las fechas. + +## Errores + +### Postulaciones + +Los errores de negocio de `/applications` usan este formato: + +```json +{ + "responseCode": "E004", + "message": "No hay un período de postulaciones abierto" +} +``` + +| HTTP | responseCode | Causa | +|---|---|---| +| 400 | E002 | Campo inválido, RUT incorrecto, correo no institucional o restricción de datos. | +| 409 | E001 | Correo ya registrado en el mismo período. | +| 409 | E004 | No hay un único período habilitado o está fuera de fechas. | +| 500 | E003 | Error interno, conexión/esquema incompatible o configuración de cifrado inválida. | + +Un JSON mal formado puede fallar antes de llegar al servicio y usar el formato general de NestJS. + +### Miembros, proyectos y eventos + +Usan excepciones HTTP estándar de NestJS, por ejemplo: + +```json +{ + "message": "limit debe ser un entero entre 1 y 100", + "error": "Bad Request", + "statusCode": 400 +} +``` + +| HTTP | Situación | +|---|---| +| 400 | Parámetro/body inválido; en POST de miembros/proyectos también algunas restricciones o referencias inexistentes. | +| 409 | Conflicto de unicidad al crear miembro/proyecto o asociaciones. No significa que el nombre del proyecto sea único. | +| 500 | Error no clasificado o fallo de BD. | +| 404 | Ruta no implementada. | + +No todos los errores SQL de miembros/proyectos se traducen a 400: enviar valores fuera del rango técnico de la BD puede terminar en 500. No depender de mensajes exactos de validación para la lógica del cliente; comprobar el estado HTTP y, en aplicaciones, responseCode. + +## Estructura y pruebas + +```text +src/ + applications/ POST de postulaciones, validación y cifrado de RUT + members/ Lectura pública y creación de miembros + projects/ Lectura pública y creación de proyectos + events/ Lectura pública de agenda + shared/connections/ Pool PostgreSQL (PG_POOL) + common/ Utilidades comunes + app.module.ts Registro de módulos + main.ts Arranque, CORS y puerto +``` + +Los controladores reciben HTTP y delegan a servicios. Los servicios validan entradas y ejecutan SQL parametrizado. Los POST con asociaciones usan una transacción y liberan su conexión. No interpolar valores del usuario en SQL. + +```sh +# Comprobación TypeScript sin generar dist +npx tsc --noEmit --incremental false + +# Pruebas unitarias +npm test -- --runInBand + +# Cobertura +npm run test:cov -- --runInBand +``` -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). +Hay pruebas de aplicaciones y eventos con pool simulado; no sustituyen pruebas de integración sobre staging. El script test:e2e del package.json referencia una configuración que no está incluida actualmente; no asumir que existe una suite E2E operativa. Los scripts lint y format modifican archivos: revisar el diff después de usarlos. -## Support +## Base de datos y pendientes -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +Los scripts y el diccionario de columnas se mantienen fuera del repositorio, en la carpeta de documentación del equipo `Knowledge/Software/ExDev`: -## Stay in touch +- `MASTER - Modelo de datos ExDev.md`: columnas, tipos, límites, relaciones y significados de negocio. +- `001_miembros_proyectos.sql`: esquema inicial consolidado. No ejecutarlo sobre tablas existentes ni usarlo como actualización incremental. +- `002_datos_iniciales_miembros_proyectos.sql`: datos iniciales de miembros/proyectos. +- `Postulaciones - cifrado y despliegue.md`: operación y gestión de claves. -- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +Solicitar esos documentos al equipo al incorporarse. Un push del código no ejecuta scripts SQL. El nuevo POST requiere periodos_postulacion y las columnas periodo_id, rut_cifrado y rut_clave_version; es incompatible con el esquema antiguo que guardaba rut en texto. -## License +Antes de desplegar: validar el esquema de staging, coordinar cambios de BD/API, configurar secretos y CORS, proteger los POST administrativos y verificar un período habilitado para probar postulaciones. No incluir datos reales en ejemplos o pruebas. -Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). +Pendientes de implementación: IAM, administración Rafael, edición y cierre de períodos, votaciones editables hasta su plazo, resolución de postulaciones, patrocinadores, detalle de eventos y storage. No hay endpoints para estas funciones salvo los documentados arriba. diff --git a/src/app.module.ts b/src/app.module.ts index 902d83f..cacc0ae 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { ApplicationsModule } from './applications/applications.module'; import { SharedModule } from './shared/shared.module'; +import { MembersModule } from './members/members.module'; +import { ProjectsModule } from './projects/projects.module'; +import { EventsModule } from './events/events.module'; @Module({ - imports: [SharedModule, ApplicationsModule], + imports: [SharedModule, ApplicationsModule, MembersModule, ProjectsModule, EventsModule], }) export class AppModule {} diff --git a/src/applications/applications.service.spec.ts b/src/applications/applications.service.spec.ts new file mode 100644 index 0000000..0376258 --- /dev/null +++ b/src/applications/applications.service.spec.ts @@ -0,0 +1,106 @@ +import { createDecipheriv } from 'node:crypto'; +import { ApplicationsService } from './applications.service'; +import { encryptRut, normalizeRut } from './rut-encryption'; + +describe('ApplicationsService with encrypted RUT', () => { + const previousVersion = process.env.RUT_ENCRYPTION_KEY_VERSION; + const previousKey = process.env.RUT_ENCRYPTION_KEY_V1; + const key = Buffer.alloc(32, 7); // Test-only key, never used outside tests. + const body = { + nombre_completo: 'Persona de prueba', rut: '12.345.678-5', + correo_institucional: 'TEST@utem.cl', carrera: 'Computación', area_interes1: 'Backend', + }; + let client: any; + let pool: any; + let service: ApplicationsService; + beforeEach(() => { + process.env.RUT_ENCRYPTION_KEY_VERSION = '1'; + process.env.RUT_ENCRYPTION_KEY_V1 = key.toString('base64'); + client = { query: jest.fn(async (sql: string) => { + if (sql.includes('SELECT id')) return { rows: [{ id: '2' }] }; + if (sql.includes('INSERT INTO')) return { rows: [{ id: '9007199254740993' }] }; + return { rows: [] }; + }), release: jest.fn() }; + pool = { connect: jest.fn(async () => client) }; + service = new ApplicationsService(pool); + }); + afterAll(() => { + if (previousVersion === undefined) delete process.env.RUT_ENCRYPTION_KEY_VERSION; + else process.env.RUT_ENCRYPTION_KEY_VERSION = previousVersion; + if (previousKey === undefined) delete process.env.RUT_ENCRYPTION_KEY_V1; + else process.env.RUT_ENCRYPTION_KEY_V1 = previousKey; + }); + it('lists applications without selecting RUT or encryption metadata', async () => { + const rows = [{ id: '1', periodo_id: '2', nombre_completo: 'Persona de prueba' }]; + pool.query = jest.fn().mockResolvedValue({ rows }); + await expect(service.findAll()).resolves.toEqual({ totalPostulaciones: 1, postulaciones: rows }); + const sql = pool.query.mock.calls[0][0]; + expect(sql).not.toMatch(/rut|SELECT\s+\*/i); + expect(sql).toContain('estado_postulacion'); + expect(sql).toContain('ORDER BY created_at DESC, id DESC'); + }); + it('returns an empty collection when there are no applications', async () => { + pool.query = jest.fn().mockResolvedValue({ rows: [] }); + await expect(service.findAll()).resolves.toEqual({ totalPostulaciones: 0, postulaciones: [] }); + }); + it('does not expose database errors from the listing', async () => { + pool.query = jest.fn().mockRejectedValue(new Error('private database detail')); + await expect(service.findAll()).rejects.toMatchObject({ + status: 500, response: { responseCode: 'E003', message: 'No se pudieron consultar las postulaciones' }, + }); + }); + it('validates the RUT check digit', () => { + expect(normalizeRut('12.345.678-5')).toBe('12345678-5'); + expect(normalizeRut('12345678-0')).toBeNull(); + }); + it('encrypts with randomized authenticated encryption', () => { + const first = encryptRut('12345678-5').ciphertext; + expect(first.equals(encryptRut('12345678-5').ciphertext)).toBe(false); + const decipher = createDecipheriv('aes-256-gcm', key, first.subarray(0, 12)); + decipher.setAAD(Buffer.from('exdev:postulaciones:rut:v1')); + decipher.setAuthTag(first.subarray(12, 28)); + expect(Buffer.concat([decipher.update(first.subarray(28)), decipher.final()]).toString()).toBe('12345678-5'); + }); + it('saves only encrypted RUT and selects the period on the server', async () => { + const result = await service.create({ ...body, periodo_id: 999 }); + expect(result.idPostulacion).toBe('9007199254740993'); + const insert = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO')); + expect(insert[1][0]).toBe('2'); + expect(Buffer.isBuffer(insert[1][1])).toBe(true); + expect(insert[1]).not.toContain('12345678-5'); + expect(insert[1]).toContain('test@utem.cl'); + expect(client.query).toHaveBeenCalledWith('COMMIT'); + expect(client.release).toHaveBeenCalledWith(false); + }); + it('rejects invalid RUT before connecting', async () => { + await expect(service.create({ ...body, rut: '12345678-0' })).rejects.toMatchObject({ status: 400 }); + expect(pool.connect).not.toHaveBeenCalled(); + }); + it('fails closed without a key', async () => { + delete process.env.RUT_ENCRYPTION_KEY_V1; + await expect(service.create(body)).rejects.toMatchObject({ status: 500 }); + expect(pool.connect).not.toHaveBeenCalled(); + }); + it('rejects when no period is enabled', async () => { + client.query.mockImplementation(async () => ({ rows: [] })); + await expect(service.create(body)).rejects.toMatchObject({ status: 409 }); + expect(client.query).toHaveBeenCalledWith('ROLLBACK'); + }); + it('rejects outside the date window after locking', async () => { + client.query.mockImplementation(async (sql: string) => ({ rows: sql.includes('FOR UPDATE') ? [{ id: '2' }] : [] })); + await expect(service.create(body)).rejects.toMatchObject({ status: 409 }); + expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO'))).toBe(false); + }); + it('does not expose database details on duplicate email', async () => { + client.query.mockImplementation(async (sql: string) => { + if (sql.includes('INSERT INTO')) throw { code: '23505', detail: 'PRIVATE DATA' }; + return { rows: [{ id: '2' }] }; + }); + try { await service.create(body); throw new Error('Expected failure'); } + catch (error) { + expect(error.getStatus()).toBe(409); + expect(JSON.stringify(error.getResponse())).not.toContain('PRIVATE DATA'); + } + expect(client.query).toHaveBeenCalledWith('ROLLBACK'); + }); +}); diff --git a/src/applications/applications.service.ts b/src/applications/applications.service.ts index 58aa6ca..6c44f28 100644 --- a/src/applications/applications.service.ts +++ b/src/applications/applications.service.ts @@ -1,118 +1,143 @@ -import { Inject, Injectable, InternalServerErrorException, HttpException } from '@nestjs/common'; -import { PG_POOL } from 'src/shared/connections/database.module'; -import { Pool } from 'pg'; -import { buildErrorExceptionPayload } from 'src/common/error-response'; +import { HttpException, Inject, Injectable } from '@nestjs/common'; +import { Pool, PoolClient } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; +import { encryptRut, normalizeRut } from './rut-encryption'; + +function fail(status: number, responseCode: string, message: string): never { + throw new HttpException({ responseCode, message }, status); +} + +function validate(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + fail(400, 'E002', 'Datos inválidos'); + } + const input = body as Record; + const data: Record = {}; + const texts: [string, number, boolean][] = [ + ['nombre_completo', 200, true], ['correo_institucional', 150, true], + ['campus', 50, false], ['carrera', 100, true], ['area_interes1', 80, true], + ['area_interes2', 80, false], ['area_interes3', 80, false], + ['ayudantias', 1000, false], ['motivo_postulacion', 5000, false], + ['proyecto_idea', 5000, false], ['portafolio', 500, false], + ['postulacion_conjunta', 500, false], ['pitch', 10000, false], ['apodo', 100, false], + ]; + for (const [field, max, required] of texts) { + const value = input[field]; + if (value != null && typeof value !== 'string') fail(400, 'E002', `Campo inválido: ${field}`); + const text = (value as string | undefined)?.trim() || null; + if ((required && !text) || (text && text.length > max)) fail(400, 'E002', `Campo inválido: ${field}`); + data[field] = text; + } + const email = (data.correo_institucional as string).toLowerCase(); + if (!/^[^\s@]+@utem\.cl$/.test(email)) fail(400, 'E002', 'Correo institucional inválido'); + data.correo_institucional = email; + const currentYear = Number(new Intl.DateTimeFormat('en', { + year: 'numeric', timeZone: 'America/Santiago', + }).format(new Date())); + for (const [field, min, max] of [ + ['edad', 16, 99], ['anio_ingreso', currentYear - 10, currentYear], + ['anio_actual', 1, 2147483647], ['horas_disponibles_semanales', 0, 2147483647], + ] as [string, number, number][]) { + const raw = input[field]; + if (raw == null || raw === '') { data[field] = null; continue; } + if (typeof raw !== 'number' && !(typeof raw === 'string' && /^\d+$/.test(raw))) { + fail(400, 'E002', `Campo inválido: ${field}`); + } + const value = Number(raw); + if (!Number.isInteger(value) || value < min || value > max) fail(400, 'E002', `Campo inválido: ${field}`); + data[field] = value; + } + const rut = normalizeRut(input.rut); + if (!rut) fail(400, 'E002', 'RUT inválido'); + data.rut = rut; + return data; +} + @Injectable() export class ApplicationsService { - constructor(@Inject(PG_POOL) private readonly pool: Pool) {} -async create(body: any){ - const sql = ` - INSERT INTO postulaciones ( - nombre_completo, - rut, - edad, - correo_institucional, - campus, - carrera, - anio_ingreso, - anio_actual, - area_interes1, - area_interes2, - area_interes3, - ayudantias, - horas_disponibles_semanales, - motivo_postulacion, - proyecto_idea, - portafolio, - postulacion_conjunta, - pitch, - apodo - ) - VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19 - ) - RETURNING id; - `; - - const values = [ - body?.nombre_completo ?? null, - body?.rut ?? null, - body?.edad ?? null, - body?.correo_institucional ?? null, - body?.campus ?? null, - body?.carrera ?? null, - body?.anio_ingreso ?? null, - body?.anio_actual ?? null, - body?.area_interes1 ?? null, - body?.area_interes2 ?? null, - body?.area_interes3 ?? null, - body?.ayudantias ?? null, - body?.horas_disponibles_semanales ?? null, - body?.motivo_postulacion ?? null, - body?.proyecto_idea ?? null, - body?.portafolio ?? null, - body?.postulacion_conjunta ?? null, - body?.pitch ?? null, - body?.apodo ?? null, - ]; + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + async create(body: unknown) { + const data = validate(body); + let client: PoolClient | undefined; + let transaction = false; + let discardConnection = false; try { - const { rows } = await this.pool.query(sql, values); + // Fail closed if the encryption key is missing. Never fall back to plaintext. + const encrypted = encryptRut(data.rut as string); + client = await this.pool.connect(); + await client.query('BEGIN'); + transaction = true; + // The lock serializes this submission against changes/cancellation of the period. + const period = await client.query(` + SELECT id FROM public.periodos_postulacion + WHERE estado_periodo = 'habilitado' + FOR UPDATE + `); + if (period.rows.length !== 1) fail(409, 'E004', 'No hay un período de postulaciones abierto'); + const periodId = period.rows[0].id; + // Read the clock AFTER acquiring the lock, not the transaction start time. + const window = await client.query(` + SELECT id FROM public.periodos_postulacion + WHERE id = $1 AND estado_periodo = 'habilitado' + AND fecha_apertura <= clock_timestamp() + AND clock_timestamp() < fecha_cierre + `, [periodId]); + if (window.rows.length !== 1) fail(409, 'E004', 'No hay un período de postulaciones abierto'); + + const fields = [ + 'nombre_completo', 'edad', 'correo_institucional', 'campus', 'carrera', + 'anio_ingreso', 'anio_actual', 'area_interes1', 'area_interes2', 'area_interes3', + 'ayudantias', 'horas_disponibles_semanales', 'motivo_postulacion', + 'proyecto_idea', 'portafolio', 'postulacion_conjunta', 'pitch', 'apodo', + ]; + const values = [periodId, encrypted.ciphertext, encrypted.keyVersion, ...fields.map(field => data[field])]; + const result = await client.query(` + INSERT INTO public.postulaciones ( + periodo_id, rut_cifrado, rut_clave_version, ${fields.join(', ')} + ) VALUES (${values.map((_, i) => `$${i + 1}`).join(', ')}) + RETURNING id + `, values); + await client.query('COMMIT'); + transaction = false; return { responseCode: 'I001', message: 'La postulacion ha sido realizada con exito', - idPostulacion: rows[0].id as number + // bigint is returned as a string by pg, avoiding precision loss. + idPostulacion: result.rows[0].id, + }; + } catch (error: unknown) { + if (client && transaction) { + try { await client.query('ROLLBACK'); } catch { discardConnection = true; } } - } catch (err: any) { - const { status, body } = buildErrorExceptionPayload(err); - throw new HttpException(body, status, { cause: err }); + if (error instanceof HttpException) throw error; + const code = (error as { code?: string })?.code; + if (code === '23505') fail(409, 'E001', 'El correo ya tiene una postulación en este período'); + if (['23514', '23502', '22P02', '22001', '22003'].includes(code)) fail(400, 'E002', 'Datos inválidos'); + // Do not expose pg details, request data, ciphertext, keys or exception causes. + fail(500, 'E003', 'No se pudo registrar la postulación'); + } finally { + client?.release(discardConnection); } } async findAll() { - const countSql = `SELECT COUNT(*)::int AS total FROM postulaciones;`; - - const listSql = ` - SELECT - id, - nombre_completo, - rut, - edad, - correo_institucional, - campus, - carrera, - anio_ingreso, - anio_actual, - area_interes1, - area_interes2, - area_interes3, - ayudantias, - horas_disponibles_semanales, - motivo_postulacion, - proyecto_idea, - portafolio, - postulacion_conjunta, - pitch, - apodo, - created_at, - updated_at - FROM postulaciones - ORDER BY created_at DESC; - `; - + // TODO(IAM): protect this administrative listing. RUT and encryption + // metadata are deliberately excluded; never replace this with SELECT *. try { - const [countRes, listRes] = await Promise.all([ - this.pool.query(countSql), - this.pool.query(listSql), - ]); - - return { - totalPostulaciones: countRes.rows[0].total as number, - postulaciones: listRes.rows, - }; - } catch (err: any) { - const { status, body } = buildErrorExceptionPayload(err); - throw new HttpException(body, status, { cause: err }); + const { rows } = await this.pool.query(` + SELECT id, periodo_id, nombre_completo, edad, correo_institucional, + campus, carrera, anio_ingreso, anio_actual, + area_interes1, area_interes2, area_interes3, ayudantias, + horas_disponibles_semanales, motivo_postulacion, proyecto_idea, + portafolio, postulacion_conjunta, pitch, apodo, + estado_postulacion, resuelta_en, resuelta_por, created_at, updated_at + FROM public.postulaciones + ORDER BY created_at DESC, id DESC + `); + return { totalPostulaciones: rows.length, postulaciones: rows }; + } catch { + fail(500, 'E003', 'No se pudieron consultar las postulaciones'); } } diff --git a/src/applications/rut-encryption.ts b/src/applications/rut-encryption.ts new file mode 100644 index 0000000..74b6592 --- /dev/null +++ b/src/applications/rut-encryption.ts @@ -0,0 +1,39 @@ +import { createCipheriv, randomBytes } from 'node:crypto'; + +/** Envelope v1: 12-byte nonce | 16-byte authentication tag | ciphertext. */ +export function encryptRut(rut: string): { ciphertext: Buffer; keyVersion: number } { + const version = process.env.RUT_ENCRYPTION_KEY_VERSION; + if (!version || !/^[1-9]\d*$/.test(version) || Number(version) > 2147483647) { + throw new Error('Invalid RUT encryption configuration'); + } + const encoded = process.env[`RUT_ENCRYPTION_KEY_V${version}`]; + const key = Buffer.from(encoded ?? '', 'base64'); + if (key.length !== 32 || key.toString('base64') !== encoded) { + throw new Error('Invalid RUT encryption configuration'); + } + const nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, nonce); + cipher.setAAD(Buffer.from(`exdev:postulaciones:rut:v${version}`)); + const ciphertext = Buffer.concat([cipher.update(rut, 'utf8'), cipher.final()]); + return { + ciphertext: Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]), + keyVersion: Number(version), + }; +} + +export function normalizeRut(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 20) return null; + const rut = value.trim().replace(/\./g, '').toUpperCase(); + if (!/^[0-9]{1,8}-[0-9K]$/.test(rut)) return null; + const [digits, verifier] = rut.split('-'); + if (Number(digits) === 0) return null; + let sum = 0; + let factor = 2; + for (let i = digits.length - 1; i >= 0; i--) { + sum += Number(digits[i]) * factor; + factor = factor === 7 ? 2 : factor + 1; + } + const remainder = 11 - (sum % 11); + const expected = remainder === 11 ? '0' : remainder === 10 ? 'K' : String(remainder); + return verifier === expected ? `${Number(digits)}-${verifier}` : null; +} diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts new file mode 100644 index 0000000..c33ec5c --- /dev/null +++ b/src/events/events.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { EventsService } from './events.service'; + +@Controller('events') +export class EventsController { + constructor(private readonly events: EventsService) {} + @Get() + findPublic(@Query('limit') limit?: string, @Query('upcoming') upcoming?: string, @Query('offset') offset?: string) { + return this.events.findPublic(limit, upcoming, offset); + } +} diff --git a/src/events/events.module.ts b/src/events/events.module.ts new file mode 100644 index 0000000..ebb3e72 --- /dev/null +++ b/src/events/events.module.ts @@ -0,0 +1,6 @@ +import { Module } from '@nestjs/common'; +import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; + +@Module({ controllers: [EventsController], providers: [EventsService] }) +export class EventsModule {} diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts new file mode 100644 index 0000000..360700d --- /dev/null +++ b/src/events/events.service.spec.ts @@ -0,0 +1,30 @@ +import { EventsService } from './events.service'; + +describe('EventsService public agenda', () => { + const pool = { query: jest.fn() }; + const service = new EventsService(pool as any); + beforeEach(() => jest.resetAllMocks()); + it('filters publication, current dates in Chile and formats date-only values', async () => { + pool.query.mockResolvedValue({ rows: [{ id: '1' }, { id: '2' }] }); + expect(await service.findPublic('1', 'true')).toEqual({ data: [{ id: '1' }], hasMore: true }); + const [sql, params] = pool.query.mock.calls[0]; + expect(sql).toContain('WHERE publicado = true'); + expect(sql).toContain("estado = 'programado'"); + expect(sql).toContain("AT TIME ZONE 'America/Santiago'"); + expect(sql).toContain("to_char(fecha_inicio, 'YYYY-MM-DD')"); + expect(params).toEqual([2, true, 0]); + }); + it('supports complete agenda pagination', async () => { + pool.query.mockResolvedValue({ rows: [] }); + expect(await service.findPublic('20', 'false', '20')).toEqual({ data: [], hasMore: false }); + expect(pool.query.mock.calls[0][1]).toEqual([21, false, 20]); + }); + it.each([['0', 'true', '0'], ['101', 'true', '0'], ['4', 'bad', '0'], ['4', 'true', '-1']])('rejects invalid query %s %s %s', async (limit, upcoming, offset) => { + await expect(service.findPublic(limit, upcoming, offset)).rejects.toMatchObject({ status: 400 }); + expect(pool.query).not.toHaveBeenCalled(); + }); + it('does not expose database failures', async () => { + pool.query.mockRejectedValue(new Error('private database detail')); + await expect(service.findPublic()).rejects.toThrow('No fue posible cargar los eventos'); + }); +}); diff --git a/src/events/events.service.ts b/src/events/events.service.ts new file mode 100644 index 0000000..981e6a8 --- /dev/null +++ b/src/events/events.service.ts @@ -0,0 +1,36 @@ +import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Pool } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; + +@Injectable() +export class EventsService { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + async findPublic(rawLimit = '20', rawUpcoming = 'true', rawOffset = '0') { + if (!/^\d+$/.test(rawLimit) || !/^\d+$/.test(rawOffset)) throw new BadRequestException('Paginación inválida'); + const limit = Number(rawLimit), offset = Number(rawOffset); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset > 1000000) throw new BadRequestException('Paginación inválida'); + if (!['true', 'false'].includes(rawUpcoming)) throw new BadRequestException('upcoming debe ser true o false'); + try { + const { rows } = await this.pool.query(` + SELECT id, tipo_evento, titulo_evento, descripcion, + to_char(fecha_inicio, 'YYYY-MM-DD') AS fecha_inicio, + to_char(fecha_fin, 'YYYY-MM-DD') AS fecha_fin, + fecha_texto, ubicacion, url_evento, tipo_accion, url_accion, estado + FROM public.eventos + WHERE publicado = true + AND (NOT $2::boolean OR ( + estado = 'programado' + AND COALESCE(fecha_fin, fecha_inicio) >= (CURRENT_TIMESTAMP AT TIME ZONE 'America/Santiago')::date + )) + ORDER BY + CASE WHEN COALESCE(fecha_fin, fecha_inicio) >= (CURRENT_TIMESTAMP AT TIME ZONE 'America/Santiago')::date THEN 0 ELSE 1 END, + CASE WHEN COALESCE(fecha_fin, fecha_inicio) >= (CURRENT_TIMESTAMP AT TIME ZONE 'America/Santiago')::date THEN fecha_inicio END ASC, + fecha_inicio DESC, id ASC + LIMIT $1 OFFSET $3 + `, [limit + 1, rawUpcoming === 'true', offset]); + return { data: rows.slice(0, limit), hasMore: rows.length > limit }; + } catch { + throw new InternalServerErrorException('No fue posible cargar los eventos'); + } + } +} diff --git a/src/main.ts b/src/main.ts index ea97106..0a59a06 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,5 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -9,14 +8,15 @@ async function bootstrap() { const expressApp = app.getHttpAdapter().getInstance(); expressApp.set('etag', false); - const WEB_ORIGINS = [ + const WEB_ORIGINS = [ 'https://dev.exdev.cl', 'https://exdev.cl', - 'https://www.exdev.cl', + 'https://www.exdev.cl', 'http://localhost:5000', 'http://localhost:3000', + 'http://127.0.0.1:3000', 'https://tomas.exdev.cl', - 'https://www.tomas.exdev.cl' + 'https://www.tomas.exdev.cl', ]; app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); @@ -26,17 +26,15 @@ async function bootstrap() { }); app.enableCors({ origin: (origin, cb) => { - if (!origin) return cb(null, true); cb(null, WEB_ORIGINS.includes(origin)); }, - methods: ['GET','POST','OPTIONS'], - allowedHeaders: ['Content-Type','Authorization'], - credentials: false, + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: false, optionsSuccessStatus: 204, }); await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000); } bootstrap(); - diff --git a/src/members/members.controller.ts b/src/members/members.controller.ts new file mode 100644 index 0000000..3296d33 --- /dev/null +++ b/src/members/members.controller.ts @@ -0,0 +1,18 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { MembersService } from './members.service'; + +@Controller('members') +export class MembersController { + constructor(private readonly membersService: MembersService) {} + + @Get() + findPublic(@Query('limit') limit?: string) { + return this.membersService.findPublic(limit); + } + + // TODO(IAM): proteger este endpoint antes de habilitarlo en producción. + @Post() + create(@Body() body: unknown) { + return this.membersService.create(body); + } +} diff --git a/src/members/members.module.ts b/src/members/members.module.ts new file mode 100644 index 0000000..33ac9e4 --- /dev/null +++ b/src/members/members.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { MembersController } from './members.controller'; +import { MembersService } from './members.service'; + +@Module({ + imports: [SharedModule], + controllers: [MembersController], + providers: [MembersService], +}) +export class MembersModule {} diff --git a/src/members/members.service.ts b/src/members/members.service.ts new file mode 100644 index 0000000..7446d17 --- /dev/null +++ b/src/members/members.service.ts @@ -0,0 +1,141 @@ +import { BadRequestException, ConflictException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Pool } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; + +@Injectable() +export class MembersService { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + + async findPublic(rawLimit?: string) { + const limit = this.parseLimit(rawLimit); + const { rows } = await this.pool.query( + ` + SELECT + m.id, + m.nombre, + m.carrera, + m.anio_ingreso_carrera, + m.foto_publica, + COALESCE(( + SELECT json_agg(r.nombre ORDER BY r.nombre) + FROM public.miembro_roles mr + JOIN public.roles_club r ON r.id = mr.rol_id + WHERE mr.miembro_id = m.id + AND mr.estado = 'activo' + AND r.estado = 'activo' + ), '[]'::json) AS roles, + COALESCE(( + SELECT json_agg(e.nombre ORDER BY e.nombre) + FROM public.miembro_especialidades me + JOIN public.especialidades e ON e.id = me.especialidad_id + WHERE me.miembro_id = m.id + AND e.estado = 'activo' + ), '[]'::json) AS especialidades + FROM public.miembros m + WHERE m.estado = 'activo' + AND m.perfil_publico = true + ORDER BY m.nombre + LIMIT $1; + `, + [limit], + ); + + return { data: rows }; + } + + async create(rawBody: unknown) { + const body = this.validateCreateBody(rawBody); + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `INSERT INTO public.miembros + (iam_subject, nombre, correo_institucional, carrera, anio_ingreso_carrera, + estado, perfil_publico, foto_publica) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING id;`, + [body.iamSubject, body.nombre, body.correoInstitucional, body.carrera, + body.anioIngresoCarrera, body.estado, body.perfilPublico, body.fotoPublica], + ); + const memberId = rows[0].id; + + for (const roleId of body.roleIds) { + await client.query( + `INSERT INTO public.miembro_roles (miembro_id, rol_id, estado) + VALUES ($1, $2, 'activo');`, + [memberId, roleId], + ); + } + for (const specialtyId of body.specialtyIds) { + await client.query( + `INSERT INTO public.miembro_especialidades (miembro_id, especialidad_id) + VALUES ($1, $2);`, + [memberId, specialtyId], + ); + } + + await client.query('COMMIT'); + return { message: 'Miembro creado correctamente', id: memberId }; + } catch (error: any) { + await client.query('ROLLBACK'); + if (error?.code === '23505') throw new ConflictException('El miembro o una de sus asignaciones ya existe'); + if (['23503', '23514', '22P02'].includes(error?.code)) throw new BadRequestException('Los datos del miembro no son válidos'); + throw new InternalServerErrorException('No fue posible crear el miembro'); + } finally { + client.release(); + } + } + + private validateCreateBody(rawBody: unknown) { + if (!rawBody || typeof rawBody !== 'object') throw new BadRequestException('El cuerpo es obligatorio'); + const body = rawBody as Record; + const nombre = this.requiredText(body.nombre, 'nombre'); + const correoInstitucional = this.requiredText(body.correoInstitucional, 'correoInstitucional').toLowerCase(); + if (!/^[^\s@]+@utem\.cl$/i.test(correoInstitucional)) { + throw new BadRequestException('correoInstitucional debe pertenecer a @utem.cl'); + } + const carrera = this.requiredText(body.carrera, 'carrera'); + const anioIngresoCarrera = Number(body.anioIngresoCarrera); + if (!Number.isInteger(anioIngresoCarrera) || anioIngresoCarrera < 1900 || anioIngresoCarrera > 2100) { + throw new BadRequestException('anioIngresoCarrera no es válido'); + } + const estado = body.estado ?? 'activo'; + if (estado !== 'activo' && estado !== 'inactivo') throw new BadRequestException('estado no es válido'); + const perfilPublico = body.perfilPublico ?? false; + const fotoPublica = body.fotoPublica ?? false; + if (typeof perfilPublico !== 'boolean' || typeof fotoPublica !== 'boolean' || (fotoPublica && !perfilPublico)) { + throw new BadRequestException('La configuración de visibilidad no es válida'); + } + return { + iamSubject: body.iamSubject === undefined || body.iamSubject === null ? null : this.requiredText(body.iamSubject, 'iamSubject'), + nombre, correoInstitucional, carrera, anioIngresoCarrera, estado, + perfilPublico, fotoPublica, + roleIds: this.idArray(body.roleIds, 'roleIds'), + specialtyIds: this.idArray(body.specialtyIds, 'specialtyIds'), + }; + } + + private requiredText(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) throw new BadRequestException(`${field} es obligatorio`); + return value.trim(); + } + + private idArray(value: unknown, field: string): number[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new BadRequestException(`${field} debe ser un arreglo`); + const ids = value.map(Number); + if (ids.some((id) => !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== ids.length) { + throw new BadRequestException(`${field} contiene IDs inválidos o repetidos`); + } + return ids; + } + + private parseLimit(rawLimit?: string): number { + if (rawLimit === undefined) return 4; + const limit = Number(rawLimit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new BadRequestException('limit debe ser un entero entre 1 y 100'); + } + return limit; + } +} diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts new file mode 100644 index 0000000..7a4468c --- /dev/null +++ b/src/projects/projects.controller.ts @@ -0,0 +1,18 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { ProjectsService } from './projects.service'; + +@Controller('projects') +export class ProjectsController { + constructor(private readonly projectsService: ProjectsService) {} + + @Get() + findPublic(@Query('limit') limit?: string, @Query('featured') featured?: string) { + return this.projectsService.findPublic(limit, featured); + } + + // TODO(IAM): proteger este endpoint antes de habilitarlo en producción. + @Post() + create(@Body() body: unknown) { + return this.projectsService.create(body); + } +} diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts new file mode 100644 index 0000000..0103857 --- /dev/null +++ b/src/projects/projects.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; + +@Module({ + imports: [SharedModule], + controllers: [ProjectsController], + providers: [ProjectsService], +}) +export class ProjectsModule {} diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts new file mode 100644 index 0000000..ebc393b --- /dev/null +++ b/src/projects/projects.service.ts @@ -0,0 +1,161 @@ +import { BadRequestException, ConflictException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Pool } from 'pg'; +import { PG_POOL } from '../shared/connections/database.module'; + +@Injectable() +export class ProjectsService { + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} + + async findPublic(rawLimit?: string, rawFeatured?: string) { + const limit = this.parseLimit(rawLimit); + const featured = this.parseFeatured(rawFeatured); + const { rows } = await this.pool.query( + ` + SELECT + p.id, + p.nombre, + p.descripcion_breve, + p.descripcion, + p.estado, + p.fecha_inicio, + p.fecha_fin, + p.destacado, + COALESCE(( + SELECT json_agg( + json_build_object('id', m.id, 'nombre', m.nombre, 'funcion', pm.funcion) + ORDER BY m.nombre + ) + FROM public.proyecto_miembros pm + JOIN public.miembros m ON m.id = pm.miembro_id + WHERE pm.proyecto_id = p.id + AND m.estado = 'activo' + AND m.perfil_publico = true + ), '[]'::json) AS miembros + FROM public.proyectos p + WHERE p.publicado = true + AND ($2::boolean IS NULL OR p.destacado = $2) + ORDER BY p.orden ASC, p.created_at DESC + LIMIT $1; + `, + [limit, featured], + ); + + return { data: rows }; + } + + async create(rawBody: unknown) { + const body = this.validateCreateBody(rawBody); + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `INSERT INTO public.proyectos + (nombre, descripcion_breve, descripcion, estado, fecha_inicio, fecha_fin, + publicado, destacado, orden) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id;`, + [body.nombre, body.descripcionBreve, body.descripcion, body.estado, + body.fechaInicio, body.fechaFin, body.publicado, body.destacado, body.orden], + ); + const projectId = rows[0].id; + + for (const member of body.members) { + await client.query( + `INSERT INTO public.proyecto_miembros + (proyecto_id, miembro_id, funcion, fecha_inicio, fecha_fin) + VALUES ($1,$2,$3,$4,$5);`, + [projectId, member.memberId, member.functionName, member.startDate, member.endDate], + ); + } + + await client.query('COMMIT'); + return { message: 'Proyecto creado correctamente', id: projectId }; + } catch (error: any) { + await client.query('ROLLBACK'); + if (error?.code === '23505') throw new ConflictException('El proyecto o uno de sus miembros ya existe'); + if (['23503', '23514', '22P02', '22007'].includes(error?.code)) throw new BadRequestException('Los datos del proyecto no son válidos'); + throw new InternalServerErrorException('No fue posible crear el proyecto'); + } finally { + client.release(); + } + } + + private validateCreateBody(rawBody: unknown) { + if (!rawBody || typeof rawBody !== 'object') throw new BadRequestException('El cuerpo es obligatorio'); + const body = rawBody as Record; + const estado = body.estado ?? 'planificacion'; + const validStates = ['planificacion', 'activo', 'bloqueado', 'pausado', 'completado', 'cancelado']; + if (typeof estado !== 'string' || !validStates.includes(estado)) throw new BadRequestException('estado no es válido'); + const publicado = body.publicado ?? false; + const destacado = body.destacado ?? false; + if (typeof publicado !== 'boolean' || typeof destacado !== 'boolean' || (destacado && !publicado)) { + throw new BadRequestException('La configuración de publicación no es válida'); + } + const orden = body.orden ?? 0; + if (!Number.isSafeInteger(orden)) throw new BadRequestException('orden debe ser un entero'); + const members = body.members ?? []; + if (!Array.isArray(members)) throw new BadRequestException('members debe ser un arreglo'); + const parsedMembers = members.map((rawMember) => this.parseMember(rawMember)); + const memberIds = parsedMembers.map((member) => member.memberId); + if (new Set(memberIds).size !== memberIds.length) throw new BadRequestException('Un miembro no puede repetirse en el proyecto'); + + return { + nombre: this.requiredText(body.nombre, 'nombre'), + descripcionBreve: this.optionalText(body.descripcionBreve, 'descripcionBreve'), + descripcion: this.optionalText(body.descripcion, 'descripcion'), + estado, + fechaInicio: this.optionalDate(body.fechaInicio, 'fechaInicio'), + fechaFin: this.optionalDate(body.fechaFin, 'fechaFin'), + publicado, destacado, orden, + members: parsedMembers, + }; + } + + private parseMember(rawMember: unknown) { + if (!rawMember || typeof rawMember !== 'object') throw new BadRequestException('Cada miembro del proyecto debe ser un objeto'); + const member = rawMember as Record; + const memberId = Number(member.memberId); + if (!Number.isSafeInteger(memberId) || memberId < 1) throw new BadRequestException('memberId no es válido'); + return { + memberId, + functionName: this.optionalText(member.functionName, 'functionName'), + startDate: this.optionalDate(member.startDate, 'startDate'), + endDate: this.optionalDate(member.endDate, 'endDate'), + }; + } + + private requiredText(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) throw new BadRequestException(`${field} es obligatorio`); + return value.trim(); + } + + private optionalText(value: unknown, field: string): string | null { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string') throw new BadRequestException(`${field} debe ser texto`); + return value.trim() || null; + } + + private optionalDate(value: unknown, field: string): string | null { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new BadRequestException(`${field} debe usar el formato YYYY-MM-DD`); + } + return value; + } + + private parseLimit(rawLimit?: string): number { + if (rawLimit === undefined) return 100; + const limit = Number(rawLimit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new BadRequestException('limit debe ser un entero entre 1 y 100'); + } + return limit; + } + + private parseFeatured(rawFeatured?: string): boolean | null { + if (rawFeatured === undefined) return null; + if (rawFeatured === 'true') return true; + if (rawFeatured === 'false') return false; + throw new BadRequestException('featured debe ser true o false'); + } +}