diff --git a/Documentation/DEVOPS.md b/Documentation/DEVOPS.md index 6f4c8d4..643c8ee 100644 --- a/Documentation/DEVOPS.md +++ b/Documentation/DEVOPS.md @@ -478,6 +478,7 @@ Base URL: `http://:8080` (sin HTTPS por ahora). Lista detallada en | GET/PUT | `/vote-config` | MASTER | configuración de votaciones (PUT crea propuesta de cambio) | | GET | `/badges` | público | catálogo de insignias (icon_url solo si hay `icon_key`) | | GET | `/subjects[/{id}]` | público | catálogo de asignaturas (con caché) | +| GET | `/courses[/{id}]` | público | catálogo de cursos con sus asignaturas (con caché) | | GET | `/stats` | público | estadísticas (archivos, espacio, descargas…) | | PUT/DELETE | `/users/me/profile-picture` | autenticado | foto de perfil (WebP + recorte, limpia el objeto S3 anterior) | | PUT | `/admin/users/{id}/ban` · `/unban` | MASTER | moderación de usuarios | diff --git a/Documentation/TESTING.md b/Documentation/TESTING.md index 0e3f000..a57d1c1 100644 --- a/Documentation/TESTING.md +++ b/Documentation/TESTING.md @@ -146,13 +146,23 @@ Archivo: [gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java] - Tras la **evicción de caché**, se re-consulta la BD. - El detalle también se sirve desde caché en la segunda petición. -### 10) StatsIntegrationTest — 2 casos +### 10) CourseIntegrationTest — 6 casos +Archivo: [gradox2/src/test/java/com/example/gradox2/CourseIntegrationTest.java](../gradox2/src/test/java/com/example/gradox2/CourseIntegrationTest.java) + +- `GET /courses` devuelve los cursos ordenados por código, cada uno con su lista de asignaturas (ordenadas por código). +- `GET /courses/{id}` devuelve el detalle del curso con sus asignaturas. +- `GET /courses/{id}` con id inexistente → `404 NOT_FOUND`. +- El catálogo de cursos se sirve desde **caché** en la segunda petición. +- Tras la **evicción de caché**, se re-consulta la BD. +- El detalle también se sirve desde caché en la segunda petición. + +### 11) StatsIntegrationTest — 2 casos Archivo: [gradox2/src/test/java/com/example/gradox2/StatsIntegrationTest.java](../gradox2/src/test/java/com/example/gradox2/StatsIntegrationTest.java) - `GET /stats` es **público** y devuelve la forma esperada (número de archivos, espacio, descargas…). - Las descargas y subidas se ven reflejadas en las estadísticas. -### 11) ProfilePictureIntegrationTest — 10 casos +### 12) ProfilePictureIntegrationTest — 10 casos Archivo: [gradox2/src/test/java/com/example/gradox2/ProfilePictureIntegrationTest.java](../gradox2/src/test/java/com/example/gradox2/ProfilePictureIntegrationTest.java) - Subir foto persiste la `profile_picture_key` (S3) y devuelve una URL firmada. @@ -165,7 +175,7 @@ Archivo: [gradox2/src/test/java/com/example/gradox2/ProfilePictureIntegrationTes - Borrar la foto limpia la clave y elimina el objeto. - Subir/borrar foto sin autenticación → `403`. -### 12) ForumThreadIntegrationTest — 20 casos +### 13) ForumThreadIntegrationTest — 20 casos Archivo: [gradox2/src/test/java/com/example/gradox2/ForumThreadIntegrationTest.java](../gradox2/src/test/java/com/example/gradox2/ForumThreadIntegrationTest.java) #### Comentarios y threads (hilo implícito por archivo) @@ -200,14 +210,14 @@ Archivo: [gradox2/src/test/java/com/example/gradox2/ForumThreadIntegrationTest.j #### Gobernanza - La eliminación de un archivo aprobada por votación elimina thread y comentarios. -### 13) GlobalJourneyIntegrationTest — 1 caso +### 14) GlobalJourneyIntegrationTest — 1 caso Archivo: [gradox2/src/test/java/com/example/gradox2/GlobalJourneyIntegrationTest.java](../gradox2/src/test/java/com/example/gradox2/GlobalJourneyIntegrationTest.java) - `fullUserJourneyFromRegistrationToLogout` - Un único test E2E que recorre el ciclo completo de un usuario: registro → verificación → login → operaciones → refresh → logout. -### 14) EmailServiceAsyncTest — 2 casos +### 15) EmailServiceAsyncTest — 2 casos Archivo: [gradox2/src/test/java/com/example/gradox2/EmailServiceAsyncTest.java](../gradox2/src/test/java/com/example/gradox2/EmailServiceAsyncTest.java) - `sendEmailRunsOnBackgroundThread` diff --git a/Documentation/openapi.json b/Documentation/openapi.json index 8234e73..cfe32fb 100644 --- a/Documentation/openapi.json +++ b/Documentation/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Gradox2 API","description":"API de gestión de archivos académicos para la aplicación Gradox2","contact":{"name":"Gradox2 Team","email":"gradox2app@gmail.com"},"license":{"name":"GPL-3.0","url":"https://www.gnu.org/licenses/gpl-3.0.html"},"version":"0.1"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Autenticación","description":"Registro, login, verificación, refresh, logout y recuperación de contraseña"},{"name":"Usuarios","description":"Perfil propio, perfiles públicos y listado de usuarios"},{"name":"Roles y Promociones","description":"Solicitudes de ascenso a MASTER y propuestas de expulsión"},{"name":"Archivos","description":"Gestión de archivos publicados: consulta, descarga, votación, visibilidad y propuestas de borrado"},{"name":"Asignaturas","description":"Catálogo de asignaturas y cursos para crear propuestas de archivo"},{"name":"Propuestas de Archivos","description":"Creación, consulta y borrado de propuestas de subida de archivos"},{"name":"Votaciones","description":"Votación sobre propuestas de subida/borrado y promociones"},{"name":"Hilos / Comentarios","description":"Gestión de hilos de discusión por archivo: comentarios, respuestas, referencias a archivos y bloqueo"},{"name":"Configuración de Votaciones","description":"Ajustes globales de quórum y umbral de aprobación (solo MASTER, vía propuesta aprobada)"},{"name":"Administración","description":"Gestión de usuarios por MASTER (baneo y rehabilitación)"},{"name":"Health Check","description":"Endpoint de verificación de estado de la aplicación"},{"name":"Administración","description":"Registro de auditoría accesible solo para MASTER"},{"name":"Administración de propuestas","description":"Acceso MASTER en solo lectura a todas las propuestas"},{"name":"Estadísticas","description":"Métricas colectivas de la plataforma para la comunidad"},{"name":"Insignias","description":"Catálogo público de insignias y sus iconos"}],"paths":{"/vote-config":{"get":{"tags":["Configuración de Votaciones"],"summary":"Ver configuración","description":"Obtiene la configuración actual del sistema de votación","operationId":"getConfig","responses":{"200":{"description":"Configuración devuelta","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GlobalConfig"}}}}}},"put":{"tags":["Configuración de Votaciones"],"summary":"Proponer cambio de configuración","description":"Crea una propuesta de cambio de política que debe ser aprobada por el 80% de los MASTER (solo MASTER)","operationId":"updateConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoteConfigUpdateRequest"}}},"required":true},"responses":{"200":{"description":"Propuesta de cambio creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ConfigProposalResponse"}}}},"401":{"description":"No autenticado"},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/users/me":{"get":{"tags":["Usuarios"],"summary":"Perfil propio","description":"Obtiene el perfil completo del usuario autenticado","operationId":"getCurrentUser","responses":{"200":{"description":"Perfil devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"403":{"description":"No autenticado"}}},"put":{"tags":["Usuarios"],"summary":"Editar perfil","description":"Actualiza nombre, alias o bio del perfil propio","operationId":"updateCurrentUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMyProfileRequest"}}},"required":true},"responses":{"200":{"description":"Perfil actualizado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"403":{"description":"No autenticado"}}}},"/users/me/profile-picture":{"put":{"tags":["Usuarios"],"summary":"Subir o actualizar foto de perfil","description":"Sube una imagen (multipart/form-data) al bucket y firma su URL de visualización","operationId":"updateProfilePicture","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Archivo de imagen"}},"required":["file"]}}},"required":true},"responses":{"200":{"description":"Foto de perfil actualizada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"400":{"description":"Imagen inválida o demasiado grande"},"403":{"description":"No autenticado"}}},"delete":{"tags":["Usuarios"],"summary":"Eliminar foto de perfil","description":"Elimina la foto de perfil del bucket y la referencia en el perfil","operationId":"deleteProfilePicture","responses":{"200":{"description":"Foto de perfil eliminada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"403":{"description":"No autenticado"}}}},"/files/{id}/visibility":{"put":{"tags":["Archivos"],"summary":"Cambiar visibilidad","description":"Cambia el nivel de visibilidad del uploader del archivo (solo el propietario o MASTER)","operationId":"updateFileVisibility","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string","description":"Nuevo nivel de visibilidad (PUBLIC, RESTRICTED, PRIVATE)","enum":["PUBLIC","RESTRICTED","PRIVATE"]}}},"required":true},"responses":{"200":{"description":"Visibilidad actualizada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"400":{"description":"No tienes permiso para cambiar la visibilidad"},"404":{"description":"Archivo no encontrado"}}}},"/files/{id}/comments/{commentId}":{"put":{"tags":["Hilos / Comentarios"],"summary":"Editar comentario","description":"Edita el contenido de un comentario propio (marca editedAt)","operationId":"editComment","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"commentId","in":"path","description":"ID del comentario","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditCommentRequest"}}},"required":true},"responses":{"200":{"description":"Comentario actualizado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommentResponse"}}}},"400":{"description":"No eres el autor del comentario"},"404":{"description":"Archivo o comentario no encontrado"}}},"delete":{"tags":["Hilos / Comentarios"],"summary":"Eliminar comentario","description":"Elimina un comentario (autor o MASTER). Si era el último, se elimina el hilo","operationId":"deleteComment","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"commentId","in":"path","description":"ID del comentario","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Comentario eliminado"},"400":{"description":"Sin permiso para eliminar"},"404":{"description":"Archivo o comentario no encontrado"}}}},"/admin/users/{id}/unban":{"put":{"tags":["Administración"],"summary":"Rehabilitar usuario","description":"Desbloquea el acceso de un usuario previamente baneado (solo MASTER)","operationId":"unbanUser","parameters":[{"name":"id","in":"path","description":"ID del usuario a rehabilitar","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Usuario rehabilitado","content":{"*/*":{"schema":{"type":"string"}}}},"403":{"description":"No autorizado (se requiere MASTER)"},"404":{"description":"Usuario no encontrado"}}}},"/admin/users/{id}/ban":{"put":{"tags":["Administración"],"summary":"Banear usuario","description":"Bloquea el acceso de un usuario (solo MASTER)","operationId":"banUser","parameters":[{"name":"id","in":"path","description":"ID del usuario a banear","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"reason","in":"query","description":"Razón del baneo (opcional)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Usuario baneado","content":{"*/*":{"schema":{"type":"string"}}}},"403":{"description":"No autorizado (se requiere MASTER)"},"404":{"description":"Usuario no encontrado"}}}},"/vote/{id}/{upvote}":{"post":{"tags":["Votaciones"],"summary":"Votar propuesta","description":"Vota a favor (true) o en contra (false) de una propuesta","operationId":"voteProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"upvote","in":"path","description":"true = a favor, false = en contra","required":true,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Voto registrado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"400":{"description":"Propuesta cerrada o ya votaste"},"404":{"description":"Propuesta no encontrada"}}}},"/uploadProposal/upload":{"post":{"tags":["Propuestas de Archivos"],"summary":"Proponer archivo","description":"Sube un archivo para su revisión mediante votación. Se especifica el nivel de visibilidad (PUBLIC, RESTRICTED, PRIVATE)","operationId":"uploadProposal","parameters":[{"name":"fileProposalRequest","in":"query","required":true,"schema":{"$ref":"#/components/schemas/UploadFileProposalRequest"}}],"responses":{"200":{"description":"Propuesta creada, pendiente de votación","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileProposalResponse"}}}},"400":{"description":"Datos inválidos o límite de subidas pendientes alcanzado"}}}},"/promoteProposal/request":{"post":{"tags":["Roles y Promociones"],"summary":"Solicitar promoción","description":"Crea una solicitud de ascenso a MASTER para el usuario autenticado","operationId":"promoteToMaster","responses":{"200":{"description":"Solicitud creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PromotionProposalResponse"}}}},"400":{"description":"Ya existe una solicitud pendiente"}}}},"/promoteProposal/demote/{id}":{"post":{"tags":["Roles y Promociones"],"summary":"Proponer expulsión","description":"Crea una propuesta de expulsión de un MASTER (solo MASTERs pueden ejecutar)","operationId":"demoteToUser","parameters":[{"name":"id","in":"path","description":"ID del MASTER a expulsar","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Propuesta de expulsión creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PromotionProposalResponse"}}}},"404":{"description":"Usuario no encontrado"}}}},"/files/{id}/vote/{upvote}":{"post":{"tags":["Archivos"],"summary":"Votar archivo","description":"Vota positivo (true) o negativo (false) para puntuar un archivo","operationId":"voteFile","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"upvote","in":"path","description":"true = positivo, false = negativo","required":true,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Voto registrado/cambiado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"404":{"description":"Archivo no encontrado"}}}},"/files/{id}/thread/lock":{"post":{"tags":["Hilos / Comentarios"],"summary":"Bloquear hilo","description":"Bloquea el hilo del archivo (uploader o MASTER): no se admiten nuevos comentarios","operationId":"lockThread","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Hilo bloqueado"},"400":{"description":"Sin permiso para bloquear"},"404":{"description":"Archivo o hilo no encontrado"}}},"delete":{"tags":["Hilos / Comentarios"],"summary":"Desbloquear hilo","description":"Desbloquea el hilo del archivo (uploader o MASTER)","operationId":"unlockThread","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Hilo desbloqueado"},"400":{"description":"Sin permiso para desbloquear"},"404":{"description":"Archivo o hilo no encontrado"}}}},"/files/{id}/comments":{"get":{"tags":["Hilos / Comentarios"],"summary":"Listar comentarios","description":"Lista los comentarios del hilo de un archivo (paginado, visibilidad del archivo aplicada)","operationId":"getThreadComments","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20,"minimum":1}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CommentResponse"}}}}},"404":{"description":"Archivo no encontrado o sin acceso"}}},"post":{"tags":["Hilos / Comentarios"],"summary":"Crear comentario","description":"Publica un comentario en el hilo del archivo (crea el hilo si no existe). Puede responder a otro comentario y/o referenciar un archivo","operationId":"createComment","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCommentRequest"}}},"required":true},"responses":{"200":{"description":"Comentario publicado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommentResponse"}}}},"400":{"description":"Datos inválidos, hilo bloqueado o padre no válido"},"404":{"description":"Archivo no encontrado o sin acceso"}}}},"/api/auth/token/refresh":{"post":{"tags":["Autenticación"],"summary":"Renovar token","description":"Obtiene un nuevo JWT usando el refresh token","operationId":"refreshToken","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenRequest"}}},"required":true},"responses":{"200":{"description":"Token renovado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"401":{"description":"Refresh token inválido o expirado"}}}},"/api/auth/register":{"post":{"tags":["Autenticación"],"summary":"Registrarse","description":"Crea una cuenta pendiente de verificación con correo institucional @rai.usc.es","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"Registro exitoso, se envía código de verificación","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"409":{"description":"El usuario o email ya existe"}}}},"/api/auth/password/reset":{"post":{"tags":["Autenticación"],"summary":"Confirmar reset de contraseña","description":"Cambia la contraseña usando el token recibido por email","operationId":"resetPassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetConfirmRequest"}}},"required":true},"responses":{"200":{"description":"Contraseña actualizada","content":{"*/*":{"schema":{"type":"string"}}}},"400":{"description":"Token inválido o expirado"}}}},"/api/auth/password/reset-request":{"post":{"tags":["Autenticación"],"summary":"Solicitar reset de contraseña","description":"Envía un enlace de restablecimiento al email si existe","operationId":"requestPasswordReset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequest"}}},"required":true},"responses":{"200":{"description":"Si el correo existe, se envía el enlace","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/api/auth/logout":{"post":{"tags":["Autenticación"],"summary":"Cerrar sesión","description":"Revoca el refresh token para cerrar la sesión","operationId":"logout","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenRequest"}}},"required":true},"responses":{"204":{"description":"Sesión cerrada"}}}},"/api/auth/login":{"post":{"tags":["Autenticación"],"summary":"Iniciar sesión","description":"Autentica al usuario con email y contraseña, devuelve JWT y refresh token","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Login exitoso","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"401":{"description":"Credenciales inválidas"},"429":{"description":"Demasiados intentos, rate limit excedido"}}}},"/vote/{id}":{"get":{"tags":["Votaciones"],"summary":"Mi voto","description":"Obtiene el voto del usuario autenticado en una propuesta","operationId":"getMyVoteProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Voto devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"404":{"description":"Propuesta o voto no encontrado"}}},"delete":{"tags":["Votaciones"],"summary":"Retirar voto","description":"Elimina el voto del usuario autenticado en una propuesta","operationId":"retractVote","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Voto retirado","content":{"*/*":{"schema":{"type":"string"}}}},"404":{"description":"Propuesta o voto no encontrado"}}}},"/vote/{id}/results":{"get":{"tags":["Votaciones"],"summary":"Resultados","description":"Obtiene el recuento actual de votos de una propuesta","operationId":"getAllVotesProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Resultados devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResultResponse"}}}},"404":{"description":"Propuesta no encontrada"}}}},"/users/{id}":{"get":{"tags":["Usuarios"],"summary":"Perfil público","description":"Obtiene el perfil público de un usuario por ID","operationId":"getUserProfile","parameters":[{"name":"id","in":"path","description":"ID del usuario","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Perfil devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PublicProfileResponse"}}}},"404":{"description":"Usuario no encontrado"}}}},"/users/all":{"get":{"tags":["Usuarios"],"summary":"Listar usuarios","description":"Lista todos los usuarios, con soporte de paginación y ordenación","operationId":"getUsers","parameters":[{"name":"paged","in":"query","description":"Activar paginación","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","description":"Número de página","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":10,"maximum":100,"minimum":1}},{"name":"sortBy","in":"query","description":"Campo de ordenación (id, username, email, role, reputation)","required":false,"schema":{"type":"string","default":"id"}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"object"}}}},"403":{"description":"No autenticado"}}}},"/uploadProposal":{"get":{"tags":["Propuestas de Archivos"],"summary":"Listar propuestas","description":"Lista todas las propuestas de archivos, con soporte de paginación y ordenación","operationId":"getAllUploadProposals","parameters":[{"name":"paged","in":"query","description":"Activar paginación","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","description":"Número de página","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":10,"maximum":100,"minimum":1}},{"name":"sortBy","in":"query","description":"Campo de ordenación (id, quorumRequired, approvalThreshold, status, actionType, createdAt, closedAt)","required":false,"schema":{"type":"string","default":"id"}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"object"}}}},"403":{"description":"No autenticado"}}}},"/uploadProposal/{id}":{"get":{"tags":["Propuestas de Archivos"],"summary":"Detalle de propuesta","description":"Obtiene los datos de una propuesta de archivo por ID","operationId":"getUploadProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Datos devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileProposalResponse"}}}},"404":{"description":"Propuesta no encontrada"}}},"delete":{"tags":["Propuestas de Archivos"],"summary":"Borrar propuesta","description":"Elimina una propuesta pendiente (solo el proponente puede borrarla)","operationId":"deleteUploadProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Propuesta eliminada","content":{"*/*":{"schema":{"type":"string"}}}},"400":{"description":"La propuesta no está pendiente o no eres el proponente"},"404":{"description":"Propuesta no encontrada"}}}},"/uploadProposal/{id}/download":{"get":{"tags":["Propuestas de Archivos"],"summary":"Descargar archivo propuesto","description":"Genera una URL firmada para descargar el archivo temporal asociado a una propuesta pendiente (válida 120 segundos)","operationId":"downloadProposalFile","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"URL firmada de descarga generada","content":{"application/json":{}}},"404":{"description":"Propuesta o archivo no encontrado"}}}},"/subjects":{"get":{"tags":["Asignaturas"],"summary":"Listar asignaturas","description":"Devuelve todas las asignaturas ordenadas por curso y código","operationId":"getAllSubjects","responses":{"200":{"description":"Lista de asignaturas devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SubjectResponse"}}}}}}}},"/subjects/{id}":{"get":{"tags":["Asignaturas"],"summary":"Ver asignatura","description":"Devuelve el detalle de una asignatura con su curso","operationId":"getSubject","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Asignatura devuelta","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SubjectResponse"}}}},"404":{"description":"Asignatura no encontrada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SubjectResponse"}}}}}}},"/stats":{"get":{"tags":["Estadísticas"],"summary":"Estadísticas de la plataforma","description":"Métricas globales: total de archivos, usuarios, almacenamiento y descargas, con desglose por tipo y asignatura","operationId":"getPlatformStats","responses":{"200":{"description":"Estadísticas devueltas","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PlatformStatsResponse"}}}}}}},"/promoteProposal/{id}":{"get":{"tags":["Roles y Promociones"],"summary":"Detalle de promoción","description":"Obtiene los datos de una solicitud de promoción por ID","operationId":"getPromoteProposalById","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Datos devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PromotionProposalResponse"}}}},"404":{"description":"Solicitud no encontrada"}}}},"/promoteProposal/pending":{"get":{"tags":["Roles y Promociones"],"summary":"Promociones pendientes","description":"Lista todas las solicitudes de promoción pendientes, con paginación opcional","operationId":"getPendingPromoteProposals","parameters":[{"name":"paged","in":"query","description":"Activar paginación","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","description":"Número de página","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":10,"maximum":100,"minimum":1}},{"name":"sortBy","in":"query","description":"Campo de ordenación","required":false,"schema":{"type":"string","default":"id"}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"object"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/files/{id}":{"get":{"tags":["Archivos"],"summary":"Detalle de archivo","description":"Obtiene los metadatos de un archivo publicados (visibilidad aplicada sobre el uploader)","operationId":"getFile","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Datos devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"404":{"description":"Archivo no encontrado"}}},"delete":{"tags":["Archivos"],"summary":"Proponer borrado","description":"Crea una propuesta de eliminación para un archivo publicado (requiere votación)","operationId":"requestFileDeletion","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Propuesta de borrado creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileProposalResponse"}}}},"400":{"description":"Ya existe una propuesta de borrado activa"},"404":{"description":"Archivo no encontrado"}}}},"/files/{id}/download":{"get":{"tags":["Archivos"],"summary":"Descargar archivo","description":"Genera una URL firmada para descargar el archivo directamente desde el bucket (válida 120 segundos)","operationId":"downloadFile","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"URL firmada de descarga generada","content":{"application/json":{}}},"404":{"description":"Archivo no encontrado"}}}},"/files/all":{"get":{"tags":["Archivos"],"summary":"Listar archivos","description":"Lista todos los archivos publicados (con visibilidad aplicada)","operationId":"getAllFiles","responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FileResponse"}}}}}}}},"/badges":{"get":{"tags":["Insignias"],"summary":"Listar insignias","description":"Lista las insignias del sistema con la URL firmada de su icono (si tiene)","operationId":"getAllBadges","responses":{"200":{"description":"Lista devuelta"}}}},"/api/auth/verify":{"get":{"tags":["Autenticación"],"summary":"Verificar cuenta","description":"Activa la cuenta mediante el token de verificación enviado por email","operationId":"verifyAccount","parameters":[{"name":"token","in":"query","description":"Token de verificación","required":true,"schema":{"type":"string","minLength":1}}],"responses":{"200":{"description":"Cuenta verificada","content":{"*/*":{"schema":{"type":"string"}}}},"400":{"description":"Token inválido o expirado"}}}},"/admin/users/banned":{"get":{"tags":["Administración"],"summary":"Listar usuarios baneados","description":"Devuelve los usuarios deshabilitados paginados (solo MASTER)","operationId":"getBannedUsers","parameters":[{"name":"page","in":"query","description":"Número de página (base 0)","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","description":"Tamaño de página","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"Listado de baneados","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageBannedUserResponse"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/admin/proposals":{"get":{"tags":["Administración de propuestas"],"summary":"Listar todas las propuestas","description":"Devuelve todas las propuestas (subidas, borrados, promociones, cambios de política) paginadas (solo MASTER)","operationId":"getAllProposals","parameters":[{"name":"page","in":"query","description":"Número de página (base 0)","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":20,"maximum":100,"minimum":1}}],"responses":{"200":{"description":"Listado de propuestas","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageAdminProposalResponse"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/admin/audit":{"get":{"tags":["Administración"],"summary":"Ver registro de auditoría","description":"Lista acciones registradas filtrables por actor, tipo, fechas y paginación (solo MASTER)","operationId":"getAuditLog","parameters":[{"name":"actorId","in":"query","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"actionType","in":"query","required":false,"schema":{"type":"string","enum":["UPLOAD","DELETE","VETO","POLICY_CHANGE","PROMOTION","EXPULSION","BAN","UNBAN"]}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"Registro de auditoría","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageAuditLogResponse"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/promoteProposal/delete":{"delete":{"tags":["Roles y Promociones"],"summary":"Cancelar solicitud","description":"Elimina la propia solicitud de promoción pendiente","operationId":"deleteMyPromoteRequest","responses":{"200":{"description":"Solicitud cancelada","content":{"*/*":{"schema":{"type":"string"}}}},"404":{"description":"No hay solicitud pendiente"}}}},"/files/{id}/vote":{"delete":{"tags":["Archivos"],"summary":"Retirar voto","description":"Elimina el voto del usuario autenticado en un archivo","operationId":"retractVote_1","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Voto retirado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"404":{"description":"Archivo o voto no encontrado"}}}},"/health":{"get":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"put":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_2","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"post":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_1","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"delete":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_3","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"options":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_6","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"head":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_5","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"patch":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_4","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"VoteConfigUpdateRequest":{"type":"object","description":"Solicitud de actualización de la configuración global de votación","properties":{"quorumRequired":{"type":"integer","format":"int32","description":"Número mínimo de votos para alcanzar quórum","example":5,"maximum":10000,"minimum":1},"approvalThreshold":{"type":"number","format":"double","description":"Umbral de aprobación (0.0 - 1.0)","example":0.6,"maximum":1.0,"minimum":0.0},"maxPendingUploads":{"type":"integer","format":"int32","description":"Límite de subidas pendientes por usuario","example":3,"minimum":1},"masterVoteWeight":{"type":"number","format":"double","description":"Peso de voto MASTER (opcional)","example":2.0,"minimum":0.1},"userVoteWeight":{"type":"number","format":"double","description":"Peso de voto de usuario (opcional)","example":1.0,"minimum":0.1}},"required":["approvalThreshold","quorumRequired"]},"ConfigProposalResponse":{"type":"object","description":"Propuesta de cambio de política de configuración","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"proposer":{"type":"string","description":"Nombre del proponente","example":"master1"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"},"quorumRequired":{"type":"integer","format":"int32","description":"Quórum propuesto","example":5},"approvalThreshold":{"type":"number","format":"double","description":"Umbral de aprobación propuesto","example":0.6},"maxPendingUploads":{"type":"integer","format":"int32","description":"Límite de subidas pendientes propuesto","example":3},"masterVoteWeight":{"type":"number","format":"double","description":"Peso de voto MASTER propuesto","example":2.0},"userVoteWeight":{"type":"number","format":"double","description":"Peso de voto de usuario propuesto","example":1.0},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación"},"endsAt":{"type":"string","format":"date-time","description":"Fecha de cierre de la votación"}}},"UpdateMyProfileRequest":{"type":"object","description":"Solicitud de actualización del perfil propio","properties":{"username":{"type":"string","description":"Nuevo nombre de usuario","example":"nuevoalias","maxLength":50,"minLength":3},"password":{"type":"string","description":"Nueva contraseña","example":"NewSecurePass1!","maxLength":72,"minLength":8}}},"MyProfileResponse":{"type":"object","description":"Perfil completo del usuario autenticado","properties":{"id":{"type":"integer","format":"int64","description":"ID del usuario","example":1},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"email":{"type":"string","description":"Correo electrónico","example":"alice@rai.usc.es"},"role":{"type":"string","description":"Rol (USER, MASTER, GUEST)","example":"USER"},"reputation":{"type":"number","format":"double","description":"Puntuación de reputación","example":4.5},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación de la cuenta"},"lastLogin":{"type":"string","format":"date-time","description":"Último inicio de sesión"},"profilePictureUrl":{"type":"string","description":"URL firmada de la foto de perfil (null si no hay foto)"},"badges":{"type":"array","description":"Insignias obtenidas","example":["PRIMER_ARCHIVO","COLABORADOR"],"items":{"type":"string"},"uniqueItems":true}}},"FileResponse":{"type":"object","description":"Respuesta con los datos de un archivo publicado","properties":{"id":{"type":"integer","format":"int64","description":"ID del archivo","example":1},"fileName":{"type":"string","description":"Nombre del archivo","example":"Apuntes Tema 1"},"description":{"type":"string","description":"Descripción del archivo","example":"Apuntes completos del tema 1"},"fileType":{"type":"string","description":"Tipo de archivo (APUNTES, EJERCICIO, EXAMEN)","enum":["APUNTES","EXAMEN","EJERCICIO","OTRO"]},"subject":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas"},"uploaderUsername":{"type":"string","description":"Nombre del uploader (según visibilidad puede ser 'anonymous')","example":"aliceuser"},"visibilityLevel":{"type":"string","description":"Nivel de visibilidad (PUBLIC, RESTRICTED, PRIVATE)","enum":["PUBLIC","RESTRICTED","PRIVATE"],"example":"PUBLIC"},"score":{"type":"number","format":"double","description":"Puntuación media (0.0 - 10.0)","example":8.5}}},"EditCommentRequest":{"type":"object","properties":{"content":{"type":"string","maxLength":2000,"minLength":0}}},"CommentResponse":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"content":{"type":"string"},"authorUsername":{"type":"string"},"parentCommentId":{"type":"integer","format":"int64"},"referencedFile":{"$ref":"#/components/schemas/FileReference"},"createdAt":{"type":"string","format":"date-time"},"editedAt":{"type":"string","format":"date-time"}}},"FileReference":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"title":{"type":"string"},"fileType":{"type":"string"},"subject":{"type":"string"},"available":{"type":"boolean"}}},"VoteResponse":{"type":"object","description":"Respuesta con los datos de un voto","properties":{"idVote":{"type":"integer","format":"int64","description":"ID del voto","example":1},"proposalId":{"type":"integer","format":"int64","description":"ID de la propuesta votada","example":1},"voter":{"type":"string","description":"Nombre del votante (puede ser 'anonymous' según visibilidad)","example":"aliceuser"},"inFavor":{"type":"boolean","description":"true = a favor, false = en contra","example":true},"votedAt":{"type":"string","format":"date-time","description":"Momento del voto"}}},"UploadFileProposalRequest":{"type":"object","description":"Solicitud de propuesta de subida de archivo (multipart/form-data)","properties":{"title":{"type":"string","description":"Título del archivo","example":"Apuntes Tema 1","maxLength":150,"minLength":0},"description":{"type":"string","description":"Descripción del archivo","example":"Apuntes completos del tema 1","maxLength":2000,"minLength":0},"type":{"type":"string","description":"Tipo de archivo (APUNTES, EJERCICIO, EXAMEN)","enum":["APUNTES","EXAMEN","EJERCICIO","OTRO"]},"subjectId":{"type":"integer","format":"int64","description":"ID de la asignatura","example":1},"file":{"type":"string","format":"binary","description":"Archivo a subir (PDF, máx 10 MB)"},"visibilityLevel":{"type":"string","description":"Nivel de visibilidad del uploader (PUBLIC, RESTRICTED, PRIVATE)","enum":["PUBLIC","RESTRICTED","PRIVATE"],"example":"PUBLIC"},"validFile":{"type":"boolean"}},"required":["file","subjectId","type"]},"FileProposalResponse":{"type":"object","description":"Respuesta con los datos de una propuesta de archivo","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"title":{"type":"string","description":"Título del archivo propuesto","example":"Apuntes Tema 1"},"description":{"type":"string","description":"Descripción del archivo","example":"Apuntes completos del tema 1"},"proposer":{"type":"string","description":"Nombre del proponente (según visibilidad puede ser 'anonymous')","example":"aliceuser"},"visibilityLevel":{"type":"string","description":"Nivel de visibilidad solicitado (PUBLIC, RESTRICTED, PRIVATE)","example":"PUBLIC"},"subjectName":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas"},"course":{"type":"string","description":"Nombre del curso","example":"1º ESO"},"type":{"type":"string","description":"Tipo de archivo (APUNTES, EJERCICIO, EXAMEN)","example":"APUNTES"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"}}},"PromotionProposalResponse":{"type":"object","description":"Respuesta con datos de una propuesta de promoción/expulsión","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"proposer":{"type":"string","description":"Nombre del proponente (puede ser 'anonymous')","example":"master1"},"candidate":{"type":"string","description":"Nombre del candidato","example":"aliceuser"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación"},"endsAt":{"type":"string","format":"date-time","description":"Fecha de cierre de la votación"}}},"CreateCommentRequest":{"type":"object","properties":{"content":{"type":"string","maxLength":2000,"minLength":0},"referencedFileId":{"type":"integer","format":"int64"},"parentCommentId":{"type":"integer","format":"int64"}}},"TokenRequest":{"type":"object","description":"Solicitud con token JWT (refresh o verificación)","properties":{"refreshToken":{"type":"string","description":"Token JWT","example":"eyJhbGciOiJIUzI1NiJ9...","minLength":1}}},"AuthResponse":{"type":"object","description":"Respuesta de autenticación con tokens JWT","properties":{"token":{"type":"string","description":"Token JWT de acceso","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":"string","description":"Token para renovar el acceso","example":"eyJhbGciOiJIUzI1NiJ9..."},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"role":{"type":"string","description":"Rol del usuario (USER, MASTER, GUEST)","example":"USER"}}},"RegisterRequest":{"type":"object","description":"Solicitud de registro de nuevo usuario","properties":{"email":{"type":"string","description":"Correo institucional (@rai.usc.es o @rai.usc.gal)","example":"alice@rai.usc.es","minLength":1,"pattern":"^[A-Za-z0-9._%+-]+@rai\\.usc\\.(es|gal)$"},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser","maxLength":50,"minLength":3},"password":{"type":"string","description":"Contraseña (mín 8 caracteres)","example":"SecurePass1!","maxLength":72,"minLength":8}}},"PasswordResetConfirmRequest":{"type":"object","description":"Confirmación de restablecimiento de contraseña con token","properties":{"token":{"type":"string","description":"Token de restablecimiento recibido por email","example":"abc123token","minLength":1},"newPassword":{"type":"string","description":"Nueva contraseña","example":"NewSecurePass1!","maxLength":72,"minLength":8}}},"PasswordResetRequest":{"type":"object","description":"Solicitud de restablecimiento de contraseña","properties":{"email":{"type":"string","description":"Correo electrónico del usuario","example":"alice@rai.usc.es","minLength":1}}},"LoginRequest":{"type":"object","description":"Solicitud de inicio de sesión","properties":{"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser","maxLength":50,"minLength":3},"password":{"type":"string","description":"Contraseña","example":"SecurePass1!","maxLength":72,"minLength":8}}},"VoteResultResponse":{"type":"object","description":"Resultado del recuento de votos de una propuesta","properties":{"proposalId":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"upvotes":{"type":"integer","format":"int64","description":"Votos a favor","example":5},"downvotes":{"type":"integer","format":"int64","description":"Votos en contra","example":2}}},"GlobalConfig":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"quorumRequired":{"type":"integer","format":"int32"},"approvalThreshold":{"type":"number","format":"double"},"maxPendingUploads":{"type":"integer","format":"int32"},"masterVoteWeight":{"type":"number","format":"double"},"userVoteWeight":{"type":"number","format":"double"}}},"PublicProfileResponse":{"type":"object","description":"Perfil público de un usuario","properties":{"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"role":{"type":"string","description":"Rol (USER, MASTER, GUEST)","example":"USER"},"reputation":{"type":"number","format":"double","description":"Puntuación de reputación","example":4.5},"profilePictureUrl":{"type":"string","description":"URL firmada de la foto de perfil (null si no hay foto)"},"badges":{"type":"array","description":"Insignias obtenidas","example":["PRIMER_ARCHIVO"],"items":{"type":"string"},"uniqueItems":true}}},"SubjectResponse":{"type":"object","description":"Respuesta con los datos de una asignatura","properties":{"id":{"type":"integer","format":"int64","description":"ID de la asignatura","example":1},"code":{"type":"string","description":"Código de la asignatura","example":"MAT1"},"name":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas I"},"courseId":{"type":"integer","format":"int64","description":"ID del curso al que pertenece","example":1},"courseCode":{"type":"string","description":"Código del curso","example":1},"courseName":{"type":"string","description":"Nombre del curso","example":"Primer curso"}}},"PlatformStatsResponse":{"type":"object","description":"Métricas colectivas de la plataforma para la comunidad","properties":{"totalFiles":{"type":"integer","format":"int64","description":"Número total de archivos publicados","example":124},"totalUsers":{"type":"integer","format":"int64","description":"Número total de usuarios registrados","example":532},"totalStorageBytes":{"type":"integer","format":"int64","description":"Tamaño total almacenado en bytes","example":1536000000},"totalDownloads":{"type":"integer","format":"int64","description":"Número total de descargas realizadas","example":8421},"byType":{"type":"array","description":"Desglose de archivos por tipo","items":{"$ref":"#/components/schemas/TypeStat"}},"bySubject":{"type":"array","description":"Desglose de archivos por asignatura","items":{"$ref":"#/components/schemas/SubjectStat"}}}},"SubjectStat":{"type":"object","description":"Archivos por asignatura","properties":{"code":{"type":"string","description":"Código de la asignatura","example":"MAT1"},"name":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas I"},"courseName":{"type":"string","description":"Nombre del curso al que pertenece","example":"Primer curso"},"count":{"type":"integer","format":"int64","description":"Número de archivos de esta asignatura","example":12}}},"TypeStat":{"type":"object","description":"Archivos por tipo","properties":{"type":{"type":"string","description":"Tipo de archivo","enum":["APUNTES","EXAMEN","EJERCICIO","OTRO"],"example":"APUNTES"},"count":{"type":"integer","format":"int64","description":"Número de archivos de este tipo","example":80}}},"BannedUserResponse":{"type":"object","description":"Usuario baneado (deshabilitado)","properties":{"id":{"type":"integer","format":"int64","description":"ID del usuario","example":1},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"email":{"type":"string","description":"Email","example":"alice@example.com"},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación de la cuenta","example":"2025-01-01T10:00:00Z"},"lastLogin":{"type":"string","format":"date-time","description":"Último inicio de sesión","example":"2025-01-01T10:00:00Z"}}},"PageBannedUserResponse":{"type":"object","properties":{"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"pageable":{"$ref":"#/components/schemas/PageableObject"},"size":{"type":"integer","format":"int32"},"content":{"type":"array","items":{"$ref":"#/components/schemas/BannedUserResponse"}},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"last":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}},"PageableObject":{"type":"object","properties":{"paged":{"type":"boolean"},"pageNumber":{"type":"integer","format":"int32"},"pageSize":{"type":"integer","format":"int32"},"unpaged":{"type":"boolean"},"offset":{"type":"integer","format":"int64"},"sort":{"$ref":"#/components/schemas/SortObject"}}},"SortObject":{"type":"object","properties":{"sorted":{"type":"boolean"},"unsorted":{"type":"boolean"},"empty":{"type":"boolean"}}},"AdminProposalResponse":{"type":"object","description":"Propuesta visible por MASTER (todos los tipos, solo lectura)","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"proposer":{"type":"string","description":"Nombre del proponente","example":"aliceuser"},"actionType":{"type":"string","description":"Tipo de acción (UPLOAD, DELETE, PROMOTION, EXPULSION, POLICY_CHANGE, VETO)","example":"UPLOAD"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"},"quorumRequired":{"type":"integer","format":"int32","description":"Quórum requerido","example":3},"approvalThreshold":{"type":"number","format":"double","description":"Umbral de aprobación","example":0.67},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación"},"endsAt":{"type":"string","format":"date-time","description":"Fecha de cierre de la votación"},"closedAt":{"type":"string","format":"date-time","description":"Fecha de resolución (null si pendiente)"},"title":{"type":"string","description":"Título del archivo (solo propuestas de archivo)"},"subjectName":{"type":"string","description":"Nombre de la asignatura (solo propuestas de archivo)"},"candidate":{"type":"string","description":"Nombre del candidato (solo propuestas de promoción/expulsión)"}}},"PageAdminProposalResponse":{"type":"object","properties":{"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"pageable":{"$ref":"#/components/schemas/PageableObject"},"size":{"type":"integer","format":"int32"},"content":{"type":"array","items":{"$ref":"#/components/schemas/AdminProposalResponse"}},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"last":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}},"AuditLogResponse":{"type":"object","description":"Entrada del registro de auditoría","properties":{"id":{"type":"integer","format":"int64","description":"ID del registro","example":1},"actor":{"type":"string","description":"Nombre del actor","example":"master1"},"actionType":{"type":"string","description":"Tipo de acción","enum":["UPLOAD","DELETE","VETO","POLICY_CHANGE","PROMOTION","EXPULSION","BAN","UNBAN"],"example":"BAN"},"targetEntity":{"type":"string","description":"Entidad sobre la que se actuó","example":"User"},"targetId":{"type":"integer","format":"int64","description":"ID de la entidad objetivo","example":42},"details":{"type":"string","description":"Detalles de la acción","example":"Usuario baneado"},"timestamp":{"type":"string","format":"date-time","description":"Fecha de la acción"}}},"PageAuditLogResponse":{"type":"object","properties":{"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"pageable":{"$ref":"#/components/schemas/PageableObject"},"size":{"type":"integer","format":"int32"},"content":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"last":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}}},"securitySchemes":{"bearerAuth":{"type":"http","name":"bearerAuth","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Gradox2 API","description":"API de gestión de archivos académicos para la aplicación Gradox2","contact":{"name":"Gradox2 Team","email":"gradox2app@gmail.com"},"license":{"name":"GPL-3.0","url":"https://www.gnu.org/licenses/gpl-3.0.html"},"version":"0.1"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Autenticación","description":"Registro, login, verificación, refresh, logout y recuperación de contraseña"},{"name":"Usuarios","description":"Perfil propio, perfiles públicos y listado de usuarios"},{"name":"Roles y Promociones","description":"Solicitudes de ascenso a MASTER y propuestas de expulsión"},{"name":"Archivos","description":"Gestión de archivos publicados: consulta, descarga, votación, visibilidad y propuestas de borrado"},{"name":"Asignaturas","description":"Catálogo de asignaturas y cursos para crear propuestas de archivo"},{"name":"Propuestas de Archivos","description":"Creación, consulta y borrado de propuestas de subida de archivos"},{"name":"Votaciones","description":"Votación sobre propuestas de subida/borrado y promociones"},{"name":"Hilos / Comentarios","description":"Gestión de hilos de discusión por archivo: comentarios, respuestas, referencias a archivos y bloqueo"},{"name":"Configuración de Votaciones","description":"Ajustes globales de quórum y umbral de aprobación (solo MASTER, vía propuesta aprobada)"},{"name":"Administración","description":"Gestión de usuarios por MASTER (baneo y rehabilitación)"},{"name":"Health Check","description":"Endpoint de verificación de estado de la aplicación"},{"name":"Administración","description":"Registro de auditoría accesible solo para MASTER"},{"name":"Administración de propuestas","description":"Acceso MASTER en solo lectura a todas las propuestas"},{"name":"Estadísticas","description":"Métricas colectivas de la plataforma para la comunidad"},{"name":"Insignias","description":"Catálogo público de insignias y sus iconos"}],"paths":{"/vote-config":{"get":{"tags":["Configuración de Votaciones"],"summary":"Ver configuración","description":"Obtiene la configuración actual del sistema de votación","operationId":"getConfig","responses":{"200":{"description":"Configuración devuelta","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GlobalConfig"}}}}}},"put":{"tags":["Configuración de Votaciones"],"summary":"Proponer cambio de configuración","description":"Crea una propuesta de cambio de política que debe ser aprobada por el 80% de los MASTER (solo MASTER)","operationId":"updateConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoteConfigUpdateRequest"}}},"required":true},"responses":{"200":{"description":"Propuesta de cambio creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ConfigProposalResponse"}}}},"401":{"description":"No autenticado"},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/users/me":{"get":{"tags":["Usuarios"],"summary":"Perfil propio","description":"Obtiene el perfil completo del usuario autenticado","operationId":"getCurrentUser","responses":{"200":{"description":"Perfil devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"403":{"description":"No autenticado"}}},"put":{"tags":["Usuarios"],"summary":"Editar perfil","description":"Actualiza nombre, alias o bio del perfil propio","operationId":"updateCurrentUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMyProfileRequest"}}},"required":true},"responses":{"200":{"description":"Perfil actualizado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"403":{"description":"No autenticado"}}}},"/users/me/profile-picture":{"put":{"tags":["Usuarios"],"summary":"Subir o actualizar foto de perfil","description":"Sube una imagen (multipart/form-data) al bucket y firma su URL de visualización","operationId":"updateProfilePicture","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Archivo de imagen"}},"required":["file"]}}},"required":true},"responses":{"200":{"description":"Foto de perfil actualizada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"400":{"description":"Imagen inválida o demasiado grande"},"403":{"description":"No autenticado"}}},"delete":{"tags":["Usuarios"],"summary":"Eliminar foto de perfil","description":"Elimina la foto de perfil del bucket y la referencia en el perfil","operationId":"deleteProfilePicture","responses":{"200":{"description":"Foto de perfil eliminada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/MyProfileResponse"}}}},"403":{"description":"No autenticado"}}}},"/files/{id}/visibility":{"put":{"tags":["Archivos"],"summary":"Cambiar visibilidad","description":"Cambia el nivel de visibilidad del uploader del archivo (solo el propietario o MASTER)","operationId":"updateFileVisibility","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string","description":"Nuevo nivel de visibilidad (PUBLIC, RESTRICTED, PRIVATE)","enum":["PUBLIC","RESTRICTED","PRIVATE"]}}},"required":true},"responses":{"200":{"description":"Visibilidad actualizada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"400":{"description":"No tienes permiso para cambiar la visibilidad"},"404":{"description":"Archivo no encontrado"}}}},"/files/{id}/comments/{commentId}":{"put":{"tags":["Hilos / Comentarios"],"summary":"Editar comentario","description":"Edita el contenido de un comentario propio (marca editedAt)","operationId":"editComment","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"commentId","in":"path","description":"ID del comentario","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditCommentRequest"}}},"required":true},"responses":{"200":{"description":"Comentario actualizado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommentResponse"}}}},"400":{"description":"No eres el autor del comentario"},"404":{"description":"Archivo o comentario no encontrado"}}},"delete":{"tags":["Hilos / Comentarios"],"summary":"Eliminar comentario","description":"Elimina un comentario (autor o MASTER). Si era el último, se elimina el hilo","operationId":"deleteComment","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"commentId","in":"path","description":"ID del comentario","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Comentario eliminado"},"400":{"description":"Sin permiso para eliminar"},"404":{"description":"Archivo o comentario no encontrado"}}}},"/admin/users/{id}/unban":{"put":{"tags":["Administración"],"summary":"Rehabilitar usuario","description":"Desbloquea el acceso de un usuario previamente baneado (solo MASTER)","operationId":"unbanUser","parameters":[{"name":"id","in":"path","description":"ID del usuario a rehabilitar","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Usuario rehabilitado","content":{"*/*":{"schema":{"type":"string"}}}},"403":{"description":"No autorizado (se requiere MASTER)"},"404":{"description":"Usuario no encontrado"}}}},"/admin/users/{id}/ban":{"put":{"tags":["Administración"],"summary":"Banear usuario","description":"Bloquea el acceso de un usuario (solo MASTER)","operationId":"banUser","parameters":[{"name":"id","in":"path","description":"ID del usuario a banear","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"reason","in":"query","description":"Razón del baneo (opcional)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Usuario baneado","content":{"*/*":{"schema":{"type":"string"}}}},"403":{"description":"No autorizado (se requiere MASTER)"},"404":{"description":"Usuario no encontrado"}}}},"/vote/{id}/{upvote}":{"post":{"tags":["Votaciones"],"summary":"Votar propuesta","description":"Vota a favor (true) o en contra (false) de una propuesta","operationId":"voteProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"upvote","in":"path","description":"true = a favor, false = en contra","required":true,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Voto registrado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"400":{"description":"Propuesta cerrada o ya votaste"},"404":{"description":"Propuesta no encontrada"}}}},"/uploadProposal/upload":{"post":{"tags":["Propuestas de Archivos"],"summary":"Proponer archivo","description":"Sube un archivo para su revisión mediante votación. Se especifica el nivel de visibilidad (PUBLIC, RESTRICTED, PRIVATE)","operationId":"uploadProposal","parameters":[{"name":"fileProposalRequest","in":"query","required":true,"schema":{"$ref":"#/components/schemas/UploadFileProposalRequest"}}],"responses":{"200":{"description":"Propuesta creada, pendiente de votación","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileProposalResponse"}}}},"400":{"description":"Datos inválidos o límite de subidas pendientes alcanzado"}}}},"/promoteProposal/request":{"post":{"tags":["Roles y Promociones"],"summary":"Solicitar promoción","description":"Crea una solicitud de ascenso a MASTER para el usuario autenticado","operationId":"promoteToMaster","responses":{"200":{"description":"Solicitud creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PromotionProposalResponse"}}}},"400":{"description":"Ya existe una solicitud pendiente"}}}},"/promoteProposal/demote/{id}":{"post":{"tags":["Roles y Promociones"],"summary":"Proponer expulsión","description":"Crea una propuesta de expulsión de un MASTER (solo MASTERs pueden ejecutar)","operationId":"demoteToUser","parameters":[{"name":"id","in":"path","description":"ID del MASTER a expulsar","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Propuesta de expulsión creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PromotionProposalResponse"}}}},"404":{"description":"Usuario no encontrado"}}}},"/files/{id}/vote/{upvote}":{"post":{"tags":["Archivos"],"summary":"Votar archivo","description":"Vota positivo (true) o negativo (false) para puntuar un archivo","operationId":"voteFile","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"upvote","in":"path","description":"true = positivo, false = negativo","required":true,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Voto registrado/cambiado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"404":{"description":"Archivo no encontrado"}}}},"/files/{id}/thread/lock":{"post":{"tags":["Hilos / Comentarios"],"summary":"Bloquear hilo","description":"Bloquea el hilo del archivo (uploader o MASTER): no se admiten nuevos comentarios","operationId":"lockThread","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Hilo bloqueado"},"400":{"description":"Sin permiso para bloquear"},"404":{"description":"Archivo o hilo no encontrado"}}},"delete":{"tags":["Hilos / Comentarios"],"summary":"Desbloquear hilo","description":"Desbloquea el hilo del archivo (uploader o MASTER)","operationId":"unlockThread","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Hilo desbloqueado"},"400":{"description":"Sin permiso para desbloquear"},"404":{"description":"Archivo o hilo no encontrado"}}}},"/files/{id}/comments":{"get":{"tags":["Hilos / Comentarios"],"summary":"Listar comentarios","description":"Lista los comentarios del hilo de un archivo (paginado, visibilidad del archivo aplicada)","operationId":"getThreadComments","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20,"minimum":1}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CommentResponse"}}}}},"404":{"description":"Archivo no encontrado o sin acceso"}}},"post":{"tags":["Hilos / Comentarios"],"summary":"Crear comentario","description":"Publica un comentario en el hilo del archivo (crea el hilo si no existe). Puede responder a otro comentario y/o referenciar un archivo","operationId":"createComment","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCommentRequest"}}},"required":true},"responses":{"200":{"description":"Comentario publicado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CommentResponse"}}}},"400":{"description":"Datos inválidos, hilo bloqueado o padre no válido"},"404":{"description":"Archivo no encontrado o sin acceso"}}}},"/api/auth/token/refresh":{"post":{"tags":["Autenticación"],"summary":"Renovar token","description":"Obtiene un nuevo JWT usando el refresh token","operationId":"refreshToken","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenRequest"}}},"required":true},"responses":{"200":{"description":"Token renovado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"401":{"description":"Refresh token inválido o expirado"}}}},"/api/auth/register":{"post":{"tags":["Autenticación"],"summary":"Registrarse","description":"Crea una cuenta pendiente de verificación con correo institucional @rai.usc.es","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"Registro exitoso, se envía código de verificación","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"409":{"description":"El usuario o email ya existe"}}}},"/api/auth/password/reset":{"post":{"tags":["Autenticación"],"summary":"Confirmar reset de contraseña","description":"Cambia la contraseña usando el token recibido por email","operationId":"resetPassword","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetConfirmRequest"}}},"required":true},"responses":{"200":{"description":"Contraseña actualizada","content":{"*/*":{"schema":{"type":"string"}}}},"400":{"description":"Token inválido o expirado"}}}},"/api/auth/password/reset-request":{"post":{"tags":["Autenticación"],"summary":"Solicitar reset de contraseña","description":"Envía un enlace de restablecimiento al email si existe","operationId":"requestPasswordReset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequest"}}},"required":true},"responses":{"200":{"description":"Si el correo existe, se envía el enlace","content":{"*/*":{"schema":{"type":"string"}}}}}}},"/api/auth/logout":{"post":{"tags":["Autenticación"],"summary":"Cerrar sesión","description":"Revoca el refresh token para cerrar la sesión","operationId":"logout","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenRequest"}}},"required":true},"responses":{"204":{"description":"Sesión cerrada"}}}},"/api/auth/login":{"post":{"tags":["Autenticación"],"summary":"Iniciar sesión","description":"Autentica al usuario con email y contraseña, devuelve JWT y refresh token","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Login exitoso","content":{"*/*":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"401":{"description":"Credenciales inválidas"},"429":{"description":"Demasiados intentos, rate limit excedido"}}}},"/vote/{id}":{"get":{"tags":["Votaciones"],"summary":"Mi voto","description":"Obtiene el voto del usuario autenticado en una propuesta","operationId":"getMyVoteProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Voto devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"404":{"description":"Propuesta o voto no encontrado"}}},"delete":{"tags":["Votaciones"],"summary":"Retirar voto","description":"Elimina el voto del usuario autenticado en una propuesta","operationId":"retractVote","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Voto retirado","content":{"*/*":{"schema":{"type":"string"}}}},"404":{"description":"Propuesta o voto no encontrado"}}}},"/vote/{id}/results":{"get":{"tags":["Votaciones"],"summary":"Resultados","description":"Obtiene el recuento actual de votos de una propuesta","operationId":"getAllVotesProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Resultados devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResultResponse"}}}},"404":{"description":"Propuesta no encontrada"}}}},"/users/{id}":{"get":{"tags":["Usuarios"],"summary":"Perfil público","description":"Obtiene el perfil público de un usuario por ID","operationId":"getUserProfile","parameters":[{"name":"id","in":"path","description":"ID del usuario","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Perfil devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PublicProfileResponse"}}}},"404":{"description":"Usuario no encontrado"}}}},"/users/all":{"get":{"tags":["Usuarios"],"summary":"Listar usuarios","description":"Lista todos los usuarios, con soporte de paginación y ordenación","operationId":"getUsers","parameters":[{"name":"paged","in":"query","description":"Activar paginación","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","description":"Número de página","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":10,"maximum":100,"minimum":1}},{"name":"sortBy","in":"query","description":"Campo de ordenación (id, username, email, role, reputation)","required":false,"schema":{"type":"string","default":"id"}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"object"}}}},"403":{"description":"No autenticado"}}}},"/uploadProposal":{"get":{"tags":["Propuestas de Archivos"],"summary":"Listar propuestas","description":"Lista todas las propuestas de archivos, con soporte de paginación y ordenación","operationId":"getAllUploadProposals","parameters":[{"name":"paged","in":"query","description":"Activar paginación","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","description":"Número de página","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":10,"maximum":100,"minimum":1}},{"name":"sortBy","in":"query","description":"Campo de ordenación (id, quorumRequired, approvalThreshold, status, actionType, createdAt, closedAt)","required":false,"schema":{"type":"string","default":"id"}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"object"}}}},"403":{"description":"No autenticado"}}}},"/uploadProposal/{id}":{"get":{"tags":["Propuestas de Archivos"],"summary":"Detalle de propuesta","description":"Obtiene los datos de una propuesta de archivo por ID","operationId":"getUploadProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Datos devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileProposalResponse"}}}},"404":{"description":"Propuesta no encontrada"}}},"delete":{"tags":["Propuestas de Archivos"],"summary":"Borrar propuesta","description":"Elimina una propuesta pendiente (solo el proponente puede borrarla)","operationId":"deleteUploadProposal","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Propuesta eliminada","content":{"*/*":{"schema":{"type":"string"}}}},"400":{"description":"La propuesta no está pendiente o no eres el proponente"},"404":{"description":"Propuesta no encontrada"}}}},"/uploadProposal/{id}/download":{"get":{"tags":["Propuestas de Archivos"],"summary":"Descargar archivo propuesto","description":"Genera una URL firmada para descargar el archivo temporal asociado a una propuesta pendiente (válida 120 segundos)","operationId":"downloadProposalFile","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"URL firmada de descarga generada","content":{"application/json":{}}},"404":{"description":"Propuesta o archivo no encontrado"}}}},"/subjects":{"get":{"tags":["Asignaturas"],"summary":"Listar asignaturas","description":"Devuelve todas las asignaturas ordenadas por curso y código","operationId":"getAllSubjects","responses":{"200":{"description":"Lista de asignaturas devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SubjectResponse"}}}}}}}},"/subjects/{id}":{"get":{"tags":["Asignaturas"],"summary":"Ver asignatura","description":"Devuelve el detalle de una asignatura con su curso","operationId":"getSubject","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Asignatura devuelta","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SubjectResponse"}}}},"404":{"description":"Asignatura no encontrada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SubjectResponse"}}}}}}},"/stats":{"get":{"tags":["Estadísticas"],"summary":"Estadísticas de la plataforma","description":"Métricas globales: total de archivos, usuarios, almacenamiento y descargas, con desglose por tipo y asignatura","operationId":"getPlatformStats","responses":{"200":{"description":"Estadísticas devueltas","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PlatformStatsResponse"}}}}}}},"/promoteProposal/{id}":{"get":{"tags":["Roles y Promociones"],"summary":"Detalle de promoción","description":"Obtiene los datos de una solicitud de promoción por ID","operationId":"getPromoteProposalById","parameters":[{"name":"id","in":"path","description":"ID de la propuesta","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Datos devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PromotionProposalResponse"}}}},"404":{"description":"Solicitud no encontrada"}}}},"/promoteProposal/pending":{"get":{"tags":["Roles y Promociones"],"summary":"Promociones pendientes","description":"Lista todas las solicitudes de promoción pendientes, con paginación opcional","operationId":"getPendingPromoteProposals","parameters":[{"name":"paged","in":"query","description":"Activar paginación","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","description":"Número de página","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":10,"maximum":100,"minimum":1}},{"name":"sortBy","in":"query","description":"Campo de ordenación","required":false,"schema":{"type":"string","default":"id"}}],"responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"object"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/files/{id}":{"get":{"tags":["Archivos"],"summary":"Detalle de archivo","description":"Obtiene los metadatos de un archivo publicados (visibilidad aplicada sobre el uploader)","operationId":"getFile","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Datos devueltos","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"404":{"description":"Archivo no encontrado"}}},"delete":{"tags":["Archivos"],"summary":"Proponer borrado","description":"Crea una propuesta de eliminación para un archivo publicado (requiere votación)","operationId":"requestFileDeletion","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Propuesta de borrado creada","content":{"*/*":{"schema":{"$ref":"#/components/schemas/FileProposalResponse"}}}},"400":{"description":"Ya existe una propuesta de borrado activa"},"404":{"description":"Archivo no encontrado"}}}},"/files/{id}/download":{"get":{"tags":["Archivos"],"summary":"Descargar archivo","description":"Genera una URL firmada para descargar el archivo directamente desde el bucket (válida 120 segundos)","operationId":"downloadFile","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"URL firmada de descarga generada","content":{"application/json":{}}},"404":{"description":"Archivo no encontrado"}}}},"/files/all":{"get":{"tags":["Archivos"],"summary":"Listar archivos","description":"Lista todos los archivos publicados (con visibilidad aplicada)","operationId":"getAllFiles","responses":{"200":{"description":"Lista devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FileResponse"}}}}}}}},"/courses":{"get":{"tags":["Asignaturas"],"summary":"Listar cursos","description":"Devuelve todos los cursos ordenados por código con sus asignaturas","operationId":"getAllCourses","responses":{"200":{"description":"Lista de cursos devuelta","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CourseResponse"}}}}}}}},"/courses/{id}":{"get":{"tags":["Asignaturas"],"summary":"Ver curso","description":"Devuelve el detalle de un curso con sus asignaturas","operationId":"getCourse","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Curso devuelto","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CourseResponse"}}}},"404":{"description":"Curso no encontrado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CourseResponse"}}}}}}},"/badges":{"get":{"tags":["Insignias"],"summary":"Listar insignias","description":"Lista las insignias del sistema con la URL firmada de su icono (si tiene)","operationId":"getAllBadges","responses":{"200":{"description":"Lista devuelta"}}}},"/api/auth/verify":{"get":{"tags":["Autenticación"],"summary":"Verificar cuenta","description":"Activa la cuenta mediante el token de verificación enviado por email","operationId":"verifyAccount","parameters":[{"name":"token","in":"query","description":"Token de verificación","required":true,"schema":{"type":"string","minLength":1}}],"responses":{"200":{"description":"Cuenta verificada","content":{"*/*":{"schema":{"type":"string"}}}},"400":{"description":"Token inválido o expirado"}}}},"/admin/users/banned":{"get":{"tags":["Administración"],"summary":"Listar usuarios baneados","description":"Devuelve los usuarios deshabilitados paginados (solo MASTER)","operationId":"getBannedUsers","parameters":[{"name":"page","in":"query","description":"Número de página (base 0)","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","description":"Tamaño de página","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"Listado de baneados","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageBannedUserResponse"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/admin/proposals":{"get":{"tags":["Administración de propuestas"],"summary":"Listar todas las propuestas","description":"Devuelve todas las propuestas (subidas, borrados, promociones, cambios de política) paginadas (solo MASTER)","operationId":"getAllProposals","parameters":[{"name":"page","in":"query","description":"Número de página (base 0)","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"size","in":"query","description":"Tamaño de página (máx 100)","required":false,"schema":{"type":"integer","format":"int32","default":20,"maximum":100,"minimum":1}}],"responses":{"200":{"description":"Listado de propuestas","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageAdminProposalResponse"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/admin/audit":{"get":{"tags":["Administración"],"summary":"Ver registro de auditoría","description":"Lista acciones registradas filtrables por actor, tipo, fechas y paginación (solo MASTER)","operationId":"getAuditLog","parameters":[{"name":"actorId","in":"query","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"actionType","in":"query","required":false,"schema":{"type":"string","enum":["UPLOAD","DELETE","VETO","POLICY_CHANGE","PROMOTION","EXPULSION","BAN","UNBAN"]}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"Registro de auditoría","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageAuditLogResponse"}}}},"403":{"description":"No autorizado (se requiere MASTER)"}}}},"/promoteProposal/delete":{"delete":{"tags":["Roles y Promociones"],"summary":"Cancelar solicitud","description":"Elimina la propia solicitud de promoción pendiente","operationId":"deleteMyPromoteRequest","responses":{"200":{"description":"Solicitud cancelada","content":{"*/*":{"schema":{"type":"string"}}}},"404":{"description":"No hay solicitud pendiente"}}}},"/files/{id}/vote":{"delete":{"tags":["Archivos"],"summary":"Retirar voto","description":"Elimina el voto del usuario autenticado en un archivo","operationId":"retractVote_1","parameters":[{"name":"id","in":"path","description":"ID del archivo","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Voto retirado","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VoteResponse"}}}},"404":{"description":"Archivo o voto no encontrado"}}}},"/health":{"get":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"put":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_2","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"post":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_1","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"delete":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_3","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"options":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_6","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"head":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_5","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}},"patch":{"tags":["Health Check"],"summary":"Health check","description":"Responde 200 OK si la aplicación está funcionando correctamente","operationId":"checkHealth_4","responses":{"200":{"description":"Aplicación activa","content":{"*/*":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"VoteConfigUpdateRequest":{"type":"object","description":"Solicitud de actualización de la configuración global de votación","properties":{"quorumRequired":{"type":"integer","format":"int32","description":"Número mínimo de votos para alcanzar quórum","example":5,"maximum":10000,"minimum":1},"approvalThreshold":{"type":"number","format":"double","description":"Umbral de aprobación (0.0 - 1.0)","example":0.6,"maximum":1.0,"minimum":0.0},"maxPendingUploads":{"type":"integer","format":"int32","description":"Límite de subidas pendientes por usuario","example":3,"minimum":1},"masterVoteWeight":{"type":"number","format":"double","description":"Peso de voto MASTER (opcional)","example":2.0,"minimum":0.1},"userVoteWeight":{"type":"number","format":"double","description":"Peso de voto de usuario (opcional)","example":1.0,"minimum":0.1}},"required":["approvalThreshold","quorumRequired"]},"ConfigProposalResponse":{"type":"object","description":"Propuesta de cambio de política de configuración","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"proposer":{"type":"string","description":"Nombre del proponente","example":"master1"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"},"quorumRequired":{"type":"integer","format":"int32","description":"Quórum propuesto","example":5},"approvalThreshold":{"type":"number","format":"double","description":"Umbral de aprobación propuesto","example":0.6},"maxPendingUploads":{"type":"integer","format":"int32","description":"Límite de subidas pendientes propuesto","example":3},"masterVoteWeight":{"type":"number","format":"double","description":"Peso de voto MASTER propuesto","example":2.0},"userVoteWeight":{"type":"number","format":"double","description":"Peso de voto de usuario propuesto","example":1.0},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación"},"endsAt":{"type":"string","format":"date-time","description":"Fecha de cierre de la votación"}}},"UpdateMyProfileRequest":{"type":"object","description":"Solicitud de actualización del perfil propio","properties":{"username":{"type":"string","description":"Nuevo nombre de usuario","example":"nuevoalias","maxLength":50,"minLength":3},"password":{"type":"string","description":"Nueva contraseña","example":"NewSecurePass1!","maxLength":72,"minLength":8}}},"MyProfileResponse":{"type":"object","description":"Perfil completo del usuario autenticado","properties":{"id":{"type":"integer","format":"int64","description":"ID del usuario","example":1},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"email":{"type":"string","description":"Correo electrónico","example":"alice@rai.usc.es"},"role":{"type":"string","description":"Rol (USER, MASTER, GUEST)","example":"USER"},"reputation":{"type":"number","format":"double","description":"Puntuación de reputación","example":4.5},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación de la cuenta"},"lastLogin":{"type":"string","format":"date-time","description":"Último inicio de sesión"},"profilePictureUrl":{"type":"string","description":"URL firmada de la foto de perfil (null si no hay foto)"},"badges":{"type":"array","description":"Insignias obtenidas","example":["PRIMER_ARCHIVO","COLABORADOR"],"items":{"type":"string"},"uniqueItems":true}}},"FileResponse":{"type":"object","description":"Respuesta con los datos de un archivo publicado","properties":{"id":{"type":"integer","format":"int64","description":"ID del archivo","example":1},"fileName":{"type":"string","description":"Nombre del archivo","example":"Apuntes Tema 1"},"description":{"type":"string","description":"Descripción del archivo","example":"Apuntes completos del tema 1"},"fileType":{"type":"string","description":"Tipo de archivo (APUNTES, EJERCICIO, EXAMEN)","enum":["APUNTES","EXAMEN","EJERCICIO","OTRO"]},"subject":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas"},"uploaderUsername":{"type":"string","description":"Nombre del uploader (según visibilidad puede ser 'anonymous')","example":"aliceuser"},"visibilityLevel":{"type":"string","description":"Nivel de visibilidad (PUBLIC, RESTRICTED, PRIVATE)","enum":["PUBLIC","RESTRICTED","PRIVATE"],"example":"PUBLIC"},"score":{"type":"number","format":"double","description":"Puntuación media (0.0 - 10.0)","example":8.5}}},"EditCommentRequest":{"type":"object","properties":{"content":{"type":"string","maxLength":2000,"minLength":0}}},"CommentResponse":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"content":{"type":"string"},"authorUsername":{"type":"string"},"parentCommentId":{"type":"integer","format":"int64"},"referencedFile":{"$ref":"#/components/schemas/FileReference"},"createdAt":{"type":"string","format":"date-time"},"editedAt":{"type":"string","format":"date-time"}}},"FileReference":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"title":{"type":"string"},"fileType":{"type":"string"},"subject":{"type":"string"},"available":{"type":"boolean"}}},"VoteResponse":{"type":"object","description":"Respuesta con los datos de un voto","properties":{"idVote":{"type":"integer","format":"int64","description":"ID del voto","example":1},"proposalId":{"type":"integer","format":"int64","description":"ID de la propuesta votada","example":1},"voter":{"type":"string","description":"Nombre del votante (puede ser 'anonymous' según visibilidad)","example":"aliceuser"},"inFavor":{"type":"boolean","description":"true = a favor, false = en contra","example":true},"votedAt":{"type":"string","format":"date-time","description":"Momento del voto"}}},"UploadFileProposalRequest":{"type":"object","description":"Solicitud de propuesta de subida de archivo (multipart/form-data)","properties":{"title":{"type":"string","description":"Título del archivo","example":"Apuntes Tema 1","maxLength":150,"minLength":0},"description":{"type":"string","description":"Descripción del archivo","example":"Apuntes completos del tema 1","maxLength":2000,"minLength":0},"type":{"type":"string","description":"Tipo de archivo (APUNTES, EJERCICIO, EXAMEN)","enum":["APUNTES","EXAMEN","EJERCICIO","OTRO"]},"subjectId":{"type":"integer","format":"int64","description":"ID de la asignatura","example":1},"file":{"type":"string","format":"binary","description":"Archivo a subir (PDF, máx 10 MB)"},"visibilityLevel":{"type":"string","description":"Nivel de visibilidad del uploader (PUBLIC, RESTRICTED, PRIVATE)","enum":["PUBLIC","RESTRICTED","PRIVATE"],"example":"PUBLIC"},"validFile":{"type":"boolean"}},"required":["file","subjectId","type"]},"FileProposalResponse":{"type":"object","description":"Respuesta con los datos de una propuesta de archivo","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"title":{"type":"string","description":"Título del archivo propuesto","example":"Apuntes Tema 1"},"description":{"type":"string","description":"Descripción del archivo","example":"Apuntes completos del tema 1"},"proposer":{"type":"string","description":"Nombre del proponente (según visibilidad puede ser 'anonymous')","example":"aliceuser"},"visibilityLevel":{"type":"string","description":"Nivel de visibilidad solicitado (PUBLIC, RESTRICTED, PRIVATE)","example":"PUBLIC"},"subjectName":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas"},"course":{"type":"string","description":"Nombre del curso","example":"1º ESO"},"type":{"type":"string","description":"Tipo de archivo (APUNTES, EJERCICIO, EXAMEN)","example":"APUNTES"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"}}},"PromotionProposalResponse":{"type":"object","description":"Respuesta con datos de una propuesta de promoción/expulsión","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"proposer":{"type":"string","description":"Nombre del proponente (puede ser 'anonymous')","example":"master1"},"candidate":{"type":"string","description":"Nombre del candidato","example":"aliceuser"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación"},"endsAt":{"type":"string","format":"date-time","description":"Fecha de cierre de la votación"}}},"CreateCommentRequest":{"type":"object","properties":{"content":{"type":"string","maxLength":2000,"minLength":0},"referencedFileId":{"type":"integer","format":"int64"},"parentCommentId":{"type":"integer","format":"int64"}}},"TokenRequest":{"type":"object","description":"Solicitud con token JWT (refresh o verificación)","properties":{"refreshToken":{"type":"string","description":"Token JWT","example":"eyJhbGciOiJIUzI1NiJ9...","minLength":1}}},"AuthResponse":{"type":"object","description":"Respuesta de autenticación con tokens JWT","properties":{"token":{"type":"string","description":"Token JWT de acceso","example":"eyJhbGciOiJIUzI1NiJ9..."},"refreshToken":{"type":"string","description":"Token para renovar el acceso","example":"eyJhbGciOiJIUzI1NiJ9..."},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"role":{"type":"string","description":"Rol del usuario (USER, MASTER, GUEST)","example":"USER"}}},"RegisterRequest":{"type":"object","description":"Solicitud de registro de nuevo usuario","properties":{"email":{"type":"string","description":"Correo institucional (@rai.usc.es o @rai.usc.gal)","example":"alice@rai.usc.es","minLength":1,"pattern":"^[A-Za-z0-9._%+-]+@rai\\.usc\\.(es|gal)$"},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser","maxLength":50,"minLength":3},"password":{"type":"string","description":"Contraseña (mín 8 caracteres)","example":"SecurePass1!","maxLength":72,"minLength":8}}},"PasswordResetConfirmRequest":{"type":"object","description":"Confirmación de restablecimiento de contraseña con token","properties":{"token":{"type":"string","description":"Token de restablecimiento recibido por email","example":"abc123token","minLength":1},"newPassword":{"type":"string","description":"Nueva contraseña","example":"NewSecurePass1!","maxLength":72,"minLength":8}}},"PasswordResetRequest":{"type":"object","description":"Solicitud de restablecimiento de contraseña","properties":{"email":{"type":"string","description":"Correo electrónico del usuario","example":"alice@rai.usc.es","minLength":1}}},"LoginRequest":{"type":"object","description":"Solicitud de inicio de sesión","properties":{"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser","maxLength":50,"minLength":3},"password":{"type":"string","description":"Contraseña","example":"SecurePass1!","maxLength":72,"minLength":8}}},"VoteResultResponse":{"type":"object","description":"Resultado del recuento de votos de una propuesta","properties":{"proposalId":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"upvotes":{"type":"integer","format":"int64","description":"Votos a favor","example":5},"downvotes":{"type":"integer","format":"int64","description":"Votos en contra","example":2}}},"GlobalConfig":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"quorumRequired":{"type":"integer","format":"int32"},"approvalThreshold":{"type":"number","format":"double"},"maxPendingUploads":{"type":"integer","format":"int32"},"masterVoteWeight":{"type":"number","format":"double"},"userVoteWeight":{"type":"number","format":"double"}}},"PublicProfileResponse":{"type":"object","description":"Perfil público de un usuario","properties":{"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"role":{"type":"string","description":"Rol (USER, MASTER, GUEST)","example":"USER"},"reputation":{"type":"number","format":"double","description":"Puntuación de reputación","example":4.5},"profilePictureUrl":{"type":"string","description":"URL firmada de la foto de perfil (null si no hay foto)"},"badges":{"type":"array","description":"Insignias obtenidas","example":["PRIMER_ARCHIVO"],"items":{"type":"string"},"uniqueItems":true}}},"SubjectResponse":{"type":"object","description":"Respuesta con los datos de una asignatura","properties":{"id":{"type":"integer","format":"int64","description":"ID de la asignatura","example":1},"code":{"type":"string","description":"Código de la asignatura","example":"MAT1"},"name":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas I"},"courseId":{"type":"integer","format":"int64","description":"ID del curso al que pertenece","example":1},"courseCode":{"type":"string","description":"Código del curso","example":1},"courseName":{"type":"string","description":"Nombre del curso","example":"Primer curso"}}},"PlatformStatsResponse":{"type":"object","description":"Métricas colectivas de la plataforma para la comunidad","properties":{"totalFiles":{"type":"integer","format":"int64","description":"Número total de archivos publicados","example":124},"totalUsers":{"type":"integer","format":"int64","description":"Número total de usuarios registrados","example":532},"totalStorageBytes":{"type":"integer","format":"int64","description":"Tamaño total almacenado en bytes","example":1536000000},"totalDownloads":{"type":"integer","format":"int64","description":"Número total de descargas realizadas","example":8421},"byType":{"type":"array","description":"Desglose de archivos por tipo","items":{"$ref":"#/components/schemas/TypeStat"}},"bySubject":{"type":"array","description":"Desglose de archivos por asignatura","items":{"$ref":"#/components/schemas/SubjectStat"}}}},"SubjectStat":{"type":"object","description":"Archivos por asignatura","properties":{"code":{"type":"string","description":"Código de la asignatura","example":"MAT1"},"name":{"type":"string","description":"Nombre de la asignatura","example":"Matemáticas I"},"courseName":{"type":"string","description":"Nombre del curso al que pertenece","example":"Primer curso"},"count":{"type":"integer","format":"int64","description":"Número de archivos de esta asignatura","example":12}}},"TypeStat":{"type":"object","description":"Archivos por tipo","properties":{"type":{"type":"string","description":"Tipo de archivo","enum":["APUNTES","EXAMEN","EJERCICIO","OTRO"],"example":"APUNTES"},"count":{"type":"integer","format":"int64","description":"Número de archivos de este tipo","example":80}}},"CourseResponse":{"type":"object","description":"Respuesta con los datos de un curso y sus asignaturas","properties":{"id":{"type":"integer","format":"int64","description":"ID del curso","example":1},"code":{"type":"string","description":"Código del curso","example":1},"name":{"type":"string","description":"Nombre del curso","example":"Primer curso"},"subjects":{"type":"array","description":"Asignaturas del curso","items":{"$ref":"#/components/schemas/CourseSubjectResponse"}}}},"CourseSubjectResponse":{"type":"object","description":"Asignatura resumida dentro de un curso","properties":{"id":{"type":"integer","format":"int64","description":"ID de la asignatura","example":1},"code":{"type":"string","description":"Código de la asignatura","example":"FMAT1"},"name":{"type":"string","description":"Nombre de la asignatura","example":"Fundamentos de Matemáticas"}}},"BannedUserResponse":{"type":"object","description":"Usuario baneado (deshabilitado)","properties":{"id":{"type":"integer","format":"int64","description":"ID del usuario","example":1},"username":{"type":"string","description":"Nombre de usuario","example":"aliceuser"},"email":{"type":"string","description":"Email","example":"alice@example.com"},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación de la cuenta","example":"2025-01-01T10:00:00Z"},"lastLogin":{"type":"string","format":"date-time","description":"Último inicio de sesión","example":"2025-01-01T10:00:00Z"}}},"PageBannedUserResponse":{"type":"object","properties":{"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"pageable":{"$ref":"#/components/schemas/PageableObject"},"size":{"type":"integer","format":"int32"},"content":{"type":"array","items":{"$ref":"#/components/schemas/BannedUserResponse"}},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"last":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}},"PageableObject":{"type":"object","properties":{"paged":{"type":"boolean"},"pageNumber":{"type":"integer","format":"int32"},"pageSize":{"type":"integer","format":"int32"},"unpaged":{"type":"boolean"},"offset":{"type":"integer","format":"int64"},"sort":{"$ref":"#/components/schemas/SortObject"}}},"SortObject":{"type":"object","properties":{"sorted":{"type":"boolean"},"unsorted":{"type":"boolean"},"empty":{"type":"boolean"}}},"AdminProposalResponse":{"type":"object","description":"Propuesta visible por MASTER (todos los tipos, solo lectura)","properties":{"id":{"type":"integer","format":"int64","description":"ID de la propuesta","example":1},"proposer":{"type":"string","description":"Nombre del proponente","example":"aliceuser"},"actionType":{"type":"string","description":"Tipo de acción (UPLOAD, DELETE, PROMOTION, EXPULSION, POLICY_CHANGE, VETO)","example":"UPLOAD"},"status":{"type":"string","description":"Estado (PENDING, APPROVED, REJECTED)","example":"PENDING"},"quorumRequired":{"type":"integer","format":"int32","description":"Quórum requerido","example":3},"approvalThreshold":{"type":"number","format":"double","description":"Umbral de aprobación","example":0.67},"createdAt":{"type":"string","format":"date-time","description":"Fecha de creación"},"endsAt":{"type":"string","format":"date-time","description":"Fecha de cierre de la votación"},"closedAt":{"type":"string","format":"date-time","description":"Fecha de resolución (null si pendiente)"},"title":{"type":"string","description":"Título del archivo (solo propuestas de archivo)"},"subjectName":{"type":"string","description":"Nombre de la asignatura (solo propuestas de archivo)"},"candidate":{"type":"string","description":"Nombre del candidato (solo propuestas de promoción/expulsión)"}}},"PageAdminProposalResponse":{"type":"object","properties":{"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"pageable":{"$ref":"#/components/schemas/PageableObject"},"size":{"type":"integer","format":"int32"},"content":{"type":"array","items":{"$ref":"#/components/schemas/AdminProposalResponse"}},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"last":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}},"AuditLogResponse":{"type":"object","description":"Entrada del registro de auditoría","properties":{"id":{"type":"integer","format":"int64","description":"ID del registro","example":1},"actor":{"type":"string","description":"Nombre del actor","example":"master1"},"actionType":{"type":"string","description":"Tipo de acción","enum":["UPLOAD","DELETE","VETO","POLICY_CHANGE","PROMOTION","EXPULSION","BAN","UNBAN"],"example":"BAN"},"targetEntity":{"type":"string","description":"Entidad sobre la que se actuó","example":"User"},"targetId":{"type":"integer","format":"int64","description":"ID de la entidad objetivo","example":42},"details":{"type":"string","description":"Detalles de la acción","example":"Usuario baneado"},"timestamp":{"type":"string","format":"date-time","description":"Fecha de la acción"}}},"PageAuditLogResponse":{"type":"object","properties":{"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"pageable":{"$ref":"#/components/schemas/PageableObject"},"size":{"type":"integer","format":"int32"},"content":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"number":{"type":"integer","format":"int32"},"sort":{"$ref":"#/components/schemas/SortObject"},"first":{"type":"boolean"},"last":{"type":"boolean"},"numberOfElements":{"type":"integer","format":"int32"},"empty":{"type":"boolean"}}}},"securitySchemes":{"bearerAuth":{"type":"http","name":"bearerAuth","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/init/DataLoader.java b/gradox2/src/main/java/com/example/gradox2/init/DataLoader.java index 8120cda..3d7af13 100644 --- a/gradox2/src/main/java/com/example/gradox2/init/DataLoader.java +++ b/gradox2/src/main/java/com/example/gradox2/init/DataLoader.java @@ -1,5 +1,4 @@ package com.example.gradox2.init; -import com.example.gradox2.persistence.entities.Course; import com.example.gradox2.persistence.entities.File; import com.example.gradox2.persistence.entities.GlobalConfig; import com.example.gradox2.persistence.entities.Subject; @@ -7,7 +6,6 @@ import com.example.gradox2.persistence.entities.enums.FileType; import com.example.gradox2.persistence.entities.enums.FileVisibility; import com.example.gradox2.persistence.entities.enums.UserRole; -import com.example.gradox2.persistence.repository.CourseRepository; import com.example.gradox2.persistence.repository.FileRepository; import com.example.gradox2.persistence.repository.SubjectRepository; import com.example.gradox2.persistence.repository.UserRepository; @@ -33,7 +31,6 @@ public class DataLoader { @Bean CommandLineRunner initDatabase(UserRepository userRepository, SubjectRepository subjectRepository, - CourseRepository courseRepository, VoteConfigRepository voteConfigRepository, FileRepository fileRepository, S3StorageService s3StorageService) { @@ -308,91 +305,47 @@ CommandLineRunner initDatabase(UserRepository userRepository, .enabled(true) .build()); - Course primero = courseRepository.save(Course.builder() - .name("Primer curso") - .code("1") - .build()); - - Course segundo = courseRepository.save(Course.builder() - .name("Segundo curso") - .code("2") - .build()); - - java.util.List subjects = subjectRepository.saveAll( - java.util.List.of( - // Subjects for "Primer curso" - com.example.gradox2.persistence.entities.Subject.builder() - .name("Matemáticas I") - .code("MAT1") - .course(primero) - .build(), - com.example.gradox2.persistence.entities.Subject.builder() - .name("Lengua Castellana I") - .code("LEN1") - .course(primero) - .build(), - com.example.gradox2.persistence.entities.Subject.builder() - .name("Ciencias Naturales I") - .code("CNA1") - .course(primero) - .build(), - // Subjects for "Segundo curso" - com.example.gradox2.persistence.entities.Subject.builder() - .name("Matemáticas II") - .code("MAT2") - .course(segundo) - .build(), - com.example.gradox2.persistence.entities.Subject.builder() - .name("Lengua Castellana II") - .code("LEN2") - .course(segundo) - .build(), - com.example.gradox2.persistence.entities.Subject.builder() - .name("Ciencias Sociales II") - .code("CSO2") - .course(segundo) - .build() - )); - - System.out.println("✅ Cursos de prueba insertados en la base de datos."); - + // El catálogo de cursos y asignaturas lo siembra la migración Flyway V8. User demoUploader = userRepository.findByUsername("juan123").orElseThrow(); - Subject subject1 = subjects.get(0); - - fileRepository.save(File.builder() - .title("Apuntes Algebra PUBLIC") - .description("Visibilidad pública") - .type(FileType.APUNTES) - .objectKey(s3StorageService.put("contenido algebra".getBytes())) - .fileHash("hash1") - .subject(subject1) - .uploader(demoUploader) - .visibilityLevel(FileVisibility.PUBLIC) - .build()); - - fileRepository.save(File.builder() - .title("Ejercicio Lengua RESTRICTED") - .description("Solo visible para USER y MASTER") - .type(FileType.EJERCICIO) - .objectKey(s3StorageService.put("contenido lengua".getBytes())) - .fileHash("hash2") - .subject(subject1) - .uploader(demoUploader) - .visibilityLevel(FileVisibility.RESTRICTED) - .build()); - - fileRepository.save(File.builder() - .title("Examen Privado PRIVATE") - .description("Solo visible para MASTER") - .type(FileType.EXAMEN) - .objectKey(s3StorageService.put("contenido examen".getBytes())) - .fileHash("hash3") - .subject(subject1) - .uploader(demoUploader) - .visibilityLevel(FileVisibility.PRIVATE) - .build()); - - System.out.println("✅ Archivos demo insertados con distintos niveles de visibilidad."); + Subject subject1 = subjectRepository.findByCode("FMAT1").orElse(null); + if (subject1 == null) { + System.out.println("ℹ️ Catálogo de asignaturas no disponible; se omite el seed de archivos demo."); + } else { + fileRepository.save(File.builder() + .title("Apuntes Algebra PUBLIC") + .description("Visibilidad pública") + .type(FileType.APUNTES) + .objectKey(s3StorageService.put("contenido algebra".getBytes())) + .fileHash("hash1") + .subject(subject1) + .uploader(demoUploader) + .visibilityLevel(FileVisibility.PUBLIC) + .build()); + + fileRepository.save(File.builder() + .title("Ejercicio Lengua RESTRICTED") + .description("Solo visible para USER y MASTER") + .type(FileType.EJERCICIO) + .objectKey(s3StorageService.put("contenido lengua".getBytes())) + .fileHash("hash2") + .subject(subject1) + .uploader(demoUploader) + .visibilityLevel(FileVisibility.RESTRICTED) + .build()); + + fileRepository.save(File.builder() + .title("Examen Privado PRIVATE") + .description("Solo visible para MASTER") + .type(FileType.EXAMEN) + .objectKey(s3StorageService.put("contenido examen".getBytes())) + .fileHash("hash3") + .subject(subject1) + .uploader(demoUploader) + .visibilityLevel(FileVisibility.PRIVATE) + .build()); + + System.out.println("✅ Archivos demo insertados con distintos niveles de visibilidad."); + } } else if (!demoSeedsEnabled) { System.out.println("ℹ️ Seed de desarrollo desactivado por defecto. Usa ENABLE_DEMO_SEEDS=true para cargar datos demo."); } diff --git a/gradox2/src/main/java/com/example/gradox2/persistence/repository/CourseRepository.java b/gradox2/src/main/java/com/example/gradox2/persistence/repository/CourseRepository.java index 7beda74..42cf07d 100644 --- a/gradox2/src/main/java/com/example/gradox2/persistence/repository/CourseRepository.java +++ b/gradox2/src/main/java/com/example/gradox2/persistence/repository/CourseRepository.java @@ -1,9 +1,20 @@ package com.example.gradox2.persistence.repository; +import java.util.List; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.example.gradox2.persistence.entities.Course; public interface CourseRepository extends JpaRepository { -} + @Query("select distinct c from Course c left join fetch c.subjects s order by c.code asc, s.code asc") + List findAllWithSubjectsByOrderByCodeAsc(); + + @Query("select distinct c from Course c left join fetch c.subjects s where c.id = :id") + Optional findByIdWithSubjects(@Param("id") Long id); + +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/persistence/repository/SubjectRepository.java b/gradox2/src/main/java/com/example/gradox2/persistence/repository/SubjectRepository.java index 8a80d80..0efb200 100644 --- a/gradox2/src/main/java/com/example/gradox2/persistence/repository/SubjectRepository.java +++ b/gradox2/src/main/java/com/example/gradox2/persistence/repository/SubjectRepository.java @@ -1,11 +1,18 @@ package com.example.gradox2.persistence.repository; +import java.util.List; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import com.example.gradox2.persistence.entities.Subject; public interface SubjectRepository extends JpaRepository { - java.util.List findAllByOrderByCourseIdAscCodeAsc(); + @Query("select s from Subject s join fetch s.course c order by c.code asc, s.code asc") + List findAllByOrderByCourseCodeAscCodeAsc(); + + Optional findByCode(String code); -} +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/presentation/controller/CourseController.java b/gradox2/src/main/java/com/example/gradox2/presentation/controller/CourseController.java new file mode 100644 index 0000000..073a865 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/presentation/controller/CourseController.java @@ -0,0 +1,46 @@ +package com.example.gradox2.presentation.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gradox2.presentation.dto.course.CourseResponse; +import com.example.gradox2.service.interfaces.ICourseService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/courses") +@Tag(name = "Asignaturas", description = "Catálogo de asignaturas y cursos para crear propuestas de archivo") +public class CourseController { + + private final ICourseService courseService; + + public CourseController(ICourseService courseService) { + this.courseService = courseService; + } + + @GetMapping + @Operation(summary = "Listar cursos", description = "Devuelve todos los cursos ordenados por código con sus asignaturas") + @ApiResponse(responseCode = "200", description = "Lista de cursos devuelta") + public ResponseEntity> getAllCourses() { + return ResponseEntity.ok(courseService.listAll()); + } + + @GetMapping("/{id}") + @Operation(summary = "Ver curso", description = "Devuelve el detalle de un curso con sus asignaturas") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Curso devuelto"), + @ApiResponse(responseCode = "404", description = "Curso no encontrado") + }) + public ResponseEntity getCourse(@PathVariable Long id) { + return ResponseEntity.ok(courseService.getById(id)); + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/presentation/dto/course/CourseResponse.java b/gradox2/src/main/java/com/example/gradox2/presentation/dto/course/CourseResponse.java new file mode 100644 index 0000000..5eb51c6 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/presentation/dto/course/CourseResponse.java @@ -0,0 +1,28 @@ +package com.example.gradox2.presentation.dto.course; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +@Builder +@Schema(description = "Respuesta con los datos de un curso y sus asignaturas") +public class CourseResponse { + @Schema(description = "ID del curso", example = "1") + private Long id; + + @Schema(description = "Código del curso", example = "1") + private String code; + + @Schema(description = "Nombre del curso", example = "Primer curso") + private String name; + + @Schema(description = "Asignaturas del curso") + private List subjects; +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/presentation/dto/course/CourseSubjectResponse.java b/gradox2/src/main/java/com/example/gradox2/presentation/dto/course/CourseSubjectResponse.java new file mode 100644 index 0000000..4454801 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/presentation/dto/course/CourseSubjectResponse.java @@ -0,0 +1,23 @@ +package com.example.gradox2.presentation.dto.course; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +@Builder +@Schema(description = "Asignatura resumida dentro de un curso") +public class CourseSubjectResponse { + @Schema(description = "ID de la asignatura", example = "1") + private Long id; + + @Schema(description = "Código de la asignatura", example = "FMAT1") + private String code; + + @Schema(description = "Nombre de la asignatura", example = "Fundamentos de Matemáticas") + private String name; +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java b/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java index da19a98..733f3bd 100644 --- a/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java +++ b/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java @@ -59,6 +59,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/admin/**").hasRole("MASTER") // Sin implementar aún .requestMatchers(HttpMethod.GET, "/files/**").permitAll() .requestMatchers(HttpMethod.GET, "/subjects/**").permitAll() + .requestMatchers(HttpMethod.GET, "/courses/**").permitAll() .requestMatchers(HttpMethod.GET, "/badges/**").permitAll() .requestMatchers(HttpMethod.GET, "/stats/**").permitAll() .requestMatchers("/api/auth/**").permitAll() diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/CourseServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/CourseServiceImpl.java new file mode 100644 index 0000000..6fa39b1 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/CourseServiceImpl.java @@ -0,0 +1,43 @@ +package com.example.gradox2.service.implementation; + +import java.util.List; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gradox2.persistence.entities.Course; +import com.example.gradox2.persistence.repository.CourseRepository; +import com.example.gradox2.presentation.dto.course.CourseResponse; +import com.example.gradox2.service.exceptions.NotFoundException; +import com.example.gradox2.service.interfaces.ICourseService; +import com.example.gradox2.utils.mapper.CourseMapper; + +@Service +public class CourseServiceImpl implements ICourseService { + + private final CourseRepository courseRepository; + + public CourseServiceImpl(CourseRepository courseRepository) { + this.courseRepository = courseRepository; + } + + @Override + @Transactional(readOnly = true) + @Cacheable("courses") + public List listAll() { + return courseRepository.findAllWithSubjectsByOrderByCodeAsc() + .stream() + .map(CourseMapper::toCourseResponse) + .toList(); + } + + @Override + @Transactional(readOnly = true) + @Cacheable(value = "courses", key = "#id") + public CourseResponse getById(Long id) { + Course course = courseRepository.findByIdWithSubjects(id) + .orElseThrow(() -> new NotFoundException("Course not found")); + return CourseMapper.toCourseResponse(course); + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java index 0342b67..22446be 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java @@ -26,7 +26,7 @@ public SubjectServiceImpl(SubjectRepository subjectRepository) { @Transactional(readOnly = true) @Cacheable("subjects") public List listAll() { - return subjectRepository.findAllByOrderByCourseIdAscCodeAsc() + return subjectRepository.findAllByOrderByCourseCodeAscCodeAsc() .stream() .map(SubjectMapper::toSubjectResponse) .toList(); diff --git a/gradox2/src/main/java/com/example/gradox2/service/interfaces/ICourseService.java b/gradox2/src/main/java/com/example/gradox2/service/interfaces/ICourseService.java new file mode 100644 index 0000000..b24085b --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/service/interfaces/ICourseService.java @@ -0,0 +1,11 @@ +package com.example.gradox2.service.interfaces; + +import java.util.List; + +import com.example.gradox2.presentation.dto.course.CourseResponse; + +public interface ICourseService { + List listAll(); + + CourseResponse getById(Long id); +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/utils/mapper/CourseMapper.java b/gradox2/src/main/java/com/example/gradox2/utils/mapper/CourseMapper.java new file mode 100644 index 0000000..cfb72e0 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/utils/mapper/CourseMapper.java @@ -0,0 +1,43 @@ +package com.example.gradox2.utils.mapper; + +import java.util.Comparator; +import java.util.List; + +import com.example.gradox2.persistence.entities.Course; +import com.example.gradox2.persistence.entities.Subject; +import com.example.gradox2.presentation.dto.course.CourseResponse; +import com.example.gradox2.presentation.dto.course.CourseSubjectResponse; + +public final class CourseMapper { + + private CourseMapper() { + } + + public static final CourseResponse toCourseResponse(Course course) { + if (course == null) { + return null; + } + + List subjects = course.getSubjects() == null + ? List.of() + : course.getSubjects().stream() + .sorted(Comparator.comparing(Subject::getCode)) + .map(CourseMapper::toCourseSubjectResponse) + .toList(); + + return CourseResponse.builder() + .id(course.getId()) + .code(course.getCode()) + .name(course.getName()) + .subjects(subjects) + .build(); + } + + private static CourseSubjectResponse toCourseSubjectResponse(Subject subject) { + return CourseSubjectResponse.builder() + .id(subject.getId()) + .code(subject.getCode()) + .name(subject.getName()) + .build(); + } +} \ No newline at end of file diff --git a/gradox2/src/main/resources/db/migration/V8__seed_courses_subjects.sql b/gradox2/src/main/resources/db/migration/V8__seed_courses_subjects.sql new file mode 100644 index 0000000..e555d20 --- /dev/null +++ b/gradox2/src/main/resources/db/migration/V8__seed_courses_subjects.sql @@ -0,0 +1,70 @@ +-- V8: catálogo definitivo de cursos y asignaturas. +-- +-- Migración puramente ADITIVA: solo INSERTs. No altera ni borra filas ni +-- esquema existentes, por lo que es seguro aplicarla sobre la base de datos +-- actual (evolución, no reemplazo). Se usa ON CONFLICT DO NOTHING para que +-- sea idempotente y respete cualquier curso/asignatura ya existente. + +insert into courses (code, name) +values ('1', 'Primer curso'), + ('2', 'Segundo curso'), + ('3', 'Tercer curso'), + ('4', 'Cuarto curso') +on conflict (code) do nothing; + +insert into subjects (code, name, course_id) +values + -- Primer curso + ('FMAT1', 'Fundamentos de Matemáticas', (select id from courses where code = '1')), + ('ALG1', 'Álgebra', (select id from courses where code = '1')), + ('MDIS1', 'Matemática Discreta', (select id from courses where code = '1')), + ('SDIG1', 'Sistemas Digitales', (select id from courses where code = '1')), + ('PROG1', 'Programación I', (select id from courses where code = '1')), + ('FTFI1', 'Fundamentos Tecnológicos y Físicos de la Informática', (select id from courses where code = '1')), + ('EST1', 'Estadística', (select id from courses where code = '1')), + ('FCOMP1','Fundamentos de Computadores', (select id from courses where code = '1')), + ('PROG2', 'Programación II', (select id from courses where code = '1')), + ('CAN1', 'Cálculo y Análisis Numérico', (select id from courses where code = '1')), + -- Segundo curso + ('GFE2', 'Gestión Financiera de Empresas', (select id from courses where code = '2')), + ('BD1', 'Bases de Datos I', (select id from courses where code = '2')), + ('AED2', 'Algoritmos y Estructuras de Datos', (select id from courses where code = '2')), + ('SO1', 'Sistemas Operativos I', (select id from courses where code = '2')), + ('RED2', 'Redes', (select id from courses where code = '2')), + ('POO2', 'Programación Orientada a Objetos', (select id from courses where code = '2')), + ('BD2', 'Bases de Datos II', (select id from courses where code = '2')), + ('SO2', 'Sistemas Operativos II', (select id from courses where code = '2')), + ('AC2', 'Arquitectura de Computadores', (select id from courses where code = '2')), + ('CG2', 'Computación Gráfica', (select id from courses where code = '2')), + ('DS2', 'Diseño de Software', (select id from courses where code = '2')), + -- Tercer curso + ('TALF3', 'Teoría de Autómatas y Lenguajes Formales', (select id from courses where code = '3')), + ('ASR3', 'Administración de Sistemas y Redes', (select id from courses where code = '3')), + ('DAW3', 'Desarrollo de Aplicaciones Web', (select id from courses where code = '3')), + ('GPI3', 'Gestión de Proyectos Informáticos', (select id from courses where code = '3')), + ('IS3', 'Ingeniería del Software', (select id from courses where code = '3')), + ('CD3', 'Computación Distribuida', (select id from courses where code = '3')), + ('CI3', 'Compiladores e Intérpretes', (select id from courses where code = '3')), + ('IA3', 'Inteligencia Artificial', (select id from courses where code = '3')), + ('SI3', 'Seguridad de la Información', (select id from courses where code = '3')), + ('CBS3', 'Ciberseguridad', (select id from courses where code = '3')), + -- Cuarto curso + ('IPO4', 'Interacción Persona-Ordenador', (select id from courses where code = '4')), + ('INGCOMP4','Ingeniería de Computadores', (select id from courses where code = '4')), + ('TFG4', 'Trabajo Fin de Grado', (select id from courses where code = '4')), + ('PEXT4', 'Prácticas Externas', (select id from courses where code = '4')), + ('FSP4', 'Fundamentos de Sistemas Paralelos', (select id from courses where code = '4')), + ('VISAV4', 'Visualización Avanzada', (select id from courses where code = '4')), + ('CALSI4', 'Calidad de los Sistemas de Información', (select id from courses where code = '4')), + ('AMD4', 'Almacenes y Minería de Datos', (select id from courses where code = '4')), + ('CRA4', 'Conocimiento y Razonamiento Automático', (select id from courses where code = '4')), + ('SINT4', 'Sistemas Inteligentes', (select id from courses where code = '4')), + ('DAR4', 'Diseño y Administración de Redes', (select id from courses where code = '4')), + ('INGSERV4','Ingeniería de Servicios', (select id from courses where code = '4')), + ('CN4', 'Computación en la Nube', (select id from courses where code = '4')), + ('CUB4', 'Computación Ubicua', (select id from courses where code = '4')), + ('PAE4', 'Programación de Arquitecturas Emergentes', (select id from courses where code = '4')), + ('GINE4', 'Gestión de Información no estructurada', (select id from courses where code = '4')), + ('MTO4', 'Modelos y Técnicas de Optimización', (select id from courses where code = '4')), + ('AA4', 'Aprendizaje Automático', (select id from courses where code = '4')) +on conflict (code) do nothing; \ No newline at end of file diff --git a/gradox2/src/test/java/com/example/gradox2/CourseIntegrationTest.java b/gradox2/src/test/java/com/example/gradox2/CourseIntegrationTest.java new file mode 100644 index 0000000..29ce118 --- /dev/null +++ b/gradox2/src/test/java/com/example/gradox2/CourseIntegrationTest.java @@ -0,0 +1,124 @@ +package com.example.gradox2; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.example.gradox2.persistence.entities.Course; +import com.example.gradox2.persistence.entities.Subject; +import com.example.gradox2.persistence.repository.CourseRepository; +import com.example.gradox2.persistence.repository.SubjectRepository; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class CourseIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SubjectRepository subjectRepository; + + @Autowired + private CacheManager cacheManager; + + @MockitoSpyBean + private CourseRepository courseRepository; + + @BeforeEach + void cleanDatabase() { + subjectRepository.deleteAll(); + courseRepository.deleteAll(); + cacheManager.getCache("courses").clear(); + cacheManager.getCache("subjects").clear(); + } + + @Test + void getAllCoursesReturnsCoursesOrderedByCodeWithSubjects() throws Exception { + Course primero = courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + Course segundo = courseRepository.save(Course.builder().code("2").name("Segundo curso").build()); + + subjectRepository.save(Subject.builder().code("FMAT1").name("Fundamentos de Matemáticas").course(primero).build()); + subjectRepository.save(Subject.builder().code("ALG1").name("Álgebra").course(primero).build()); + subjectRepository.save(Subject.builder().code("BD1").name("Bases de Datos I").course(segundo).build()); + + mockMvc.perform(get("/courses")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].code").value("1")) + .andExpect(jsonPath("$[0].name").value("Primer curso")) + .andExpect(jsonPath("$[0].subjects.length()").value(2)) + .andExpect(jsonPath("$[0].subjects[0].code").value("ALG1")) + .andExpect(jsonPath("$[0].subjects[1].code").value("FMAT1")) + .andExpect(jsonPath("$[1].code").value("2")) + .andExpect(jsonPath("$[1].subjects[0].name").value("Bases de Datos I")); + } + + @Test + void getCourseReturnsDetailWithSubjects() throws Exception { + Course curso = courseRepository.save(Course.builder().code("3").name("Tercer curso").build()); + subjectRepository.save(Subject.builder().code("IA3").name("Inteligencia Artificial").course(curso).build()); + + mockMvc.perform(get("/courses/{id}", curso.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(curso.getId())) + .andExpect(jsonPath("$.code").value("3")) + .andExpect(jsonPath("$.name").value("Tercer curso")) + .andExpect(jsonPath("$.subjects.length()").value(1)) + .andExpect(jsonPath("$.subjects[0].code").value("IA3")); + } + + @Test + void getCourseReturns404WhenMissing() throws Exception { + mockMvc.perform(get("/courses/{id}", 999999L)) + .andExpect(status().isNotFound()); + } + + @Test + void getAllCoursesIsServedFromCacheOnSecondRequest() throws Exception { + courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + + mockMvc.perform(get("/courses")).andExpect(status().isOk()); + mockMvc.perform(get("/courses")).andExpect(status().isOk()); + mockMvc.perform(get("/courses")).andExpect(status().isOk()); + + verify(courseRepository, times(1)) + .findAllWithSubjectsByOrderByCodeAsc(); + } + + @Test + void getAllCoursesRehitsDatabaseAfterCacheEviction() throws Exception { + courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + + mockMvc.perform(get("/courses")).andExpect(status().isOk()); + cacheManager.getCache("courses").clear(); + + mockMvc.perform(get("/courses")).andExpect(status().isOk()); + + verify(courseRepository, times(2)) + .findAllWithSubjectsByOrderByCodeAsc(); + } + + @Test + void getCourseIsServedFromCacheOnSecondRequest() throws Exception { + Course curso = courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + + mockMvc.perform(get("/courses/{id}", curso.getId())).andExpect(status().isOk()); + mockMvc.perform(get("/courses/{id}", curso.getId())).andExpect(status().isOk()); + + verify(courseRepository, times(1)).findByIdWithSubjects(curso.getId()); + } +} \ No newline at end of file diff --git a/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java b/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java index a6043ab..6d5394e 100644 --- a/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java +++ b/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java @@ -94,7 +94,7 @@ void getAllSubjectsIsServedFromCacheOnSecondRequest() throws Exception { mockMvc.perform(get("/subjects")).andExpect(status().isOk()); verify(subjectRepository, times(1)) - .findAllByOrderByCourseIdAscCodeAsc(); + .findAllByOrderByCourseCodeAscCodeAsc(); } @Test @@ -108,7 +108,7 @@ void getAllSubjectsRehitsDatabaseAfterCacheEviction() throws Exception { mockMvc.perform(get("/subjects")).andExpect(status().isOk()); verify(subjectRepository, times(2)) - .findAllByOrderByCourseIdAscCodeAsc(); + .findAllByOrderByCourseCodeAscCodeAsc(); } @Test