SIENEP · del navegador a la base

Incidencias y confidencialidad

Un funcionario registra que un estudiante faltó a un encuentro. Más tarde, otro integrante del equipo abre la misma incidencia desde el historial. Ambos pueden acceder al hecho general, pero la observación reservada se entrega solo a quien tiene el permiso correspondiente. El recorrido sigue ese caso desde React hasta Spring y PostgreSQL.

RF28 y RF29 · Código del paquete del 06/11/2026 · React + Spring + PostgreSQL

Requisitos funcionales del recorrido

Fuente oficial Requerimientos de Programación de UTEC, 2026 · p. 5

Los datos, IDs y credenciales de los ejemplos son ficticios. Podés copiar los fragmentos o imprimir los cinco recorridos. El HTML se abre sin internet.

Registrar una incidencia

Ficha del estudiante → formulario → POST → instancia e incidencia → confirmación

1. Abrir el formulario y elegir al estudiante

Ruta · ID ficticio
/incidencias?nueva=1
/incidencias/nueva?estudianteOrigen=501

Desde el listado se abre Nueva incidencia con un selector. Desde la ficha se abre con el estudiante de origen ya seleccionado. La interfaz pide incidencias.crear y estudiantes.leer para cargar el formulario y sus datos.

PaginaNuevaIncidencia.tsx · líneas 70–74

TypeScript
const consulta = origen ? obtenerEstudiante(origen, { senal: abortador.signal }).then((alumno) => [alumno]) : listarEstudiantes({ senal: abortador.signal })
    void consulta.then((datos) => {
      if (!vigente()) return
      const minimos = datos.map(({ idUsuario, nombre, apellido, estActivo, terminosAceptados }) => ({ idUsuario, nombre, apellido, estActivo, terminosAceptados }))
      setAlumnos(minimos.filter(alumnoUsable)); setEstado('listo')

React consulta la ficha de origen o el listado de estudiantes. En ambos caminos conserva los estudiantes activos con términos aceptados. El servidor verifica el alcance de creación al guardar, independientemente de qué opciones se cargaron en pantalla.

2. Completar el hecho y declarar quiénes participaron

  • Estudiante obligatorio y fecha con hora de Montevideo. La fecha del hecho admite pasado y presente.
  • Lugar de hasta 100 caracteres y descripción general de hasta 255. Ambos son obligatorios.
  • El formulario pregunta si hubo otras personas. Con una respuesta afirmativa admite entre 1 y 20 entradas de texto, de hasta 100 caracteres cada una.
  • Las observaciones confidenciales son opcionales, admiten hasta 4.000 caracteres y requieren su propio permiso.

validacion.ts · líneas 63–65

TypeScript
idEstudiante, fecHora, lugar: stripJava(datos.lugar), descripcion: stripJava(datos.descripcion),
    personasInvolucradas: datos.otrasPersonas === 'si' ? datos.personas.map(stripJava) : [],
    ...(confidencial && observaciones ? { observacionesConfidenciales: observaciones } : {}),

La fecha viaja en ISO 8601 con offset. Las personas son textos ordenados y se guardan como parte del registro. En la API, [] declara que no hubo otras personas. Una respuesta con null indica que ese dato nunca se registró, por ejemplo en una incidencia histórica.

3. Enviar el POST con la identidad de la sesión

FormularioIncidencia.tsx · líneas 106–109

TypeScript
const permisosActuales = await refrescar()
      if (!vigente() || !permisosActuales?.includes('incidencias.crear') || !permisosActuales.includes('estudiantes.leer')) return
      const permiteReserva = permisosActuales.includes('incidencias.confidencial')
      const body = construirBody(datos, permiteReserva, permiteReserva ? refObservaciones.current?.value : undefined)
HTTP · datos ficticios
POST /api/incidencias
Content-Type: application/json
Authorization: Bearer CREDENCIAL_DE_EJEMPLO

{
  "idEstudiante": 501,
  "fecHora": "2026-11-05T14:30:00-03:00",
  "lugar": "Sala de seguimiento",
  "descripcion": "Se registró una inasistencia al encuentro acordado.",
  "personasInvolucradas": ["Referente de turno"],
  "observacionesConfidenciales": "Detalle reservado de ejemplo."
}

IncidenciaController.java · líneas 70–78

Java
@PreAuthorize("hasAuthority('incidencias.crear')")
    @PostMapping
    public ResponseEntity<IncidenciaResponseDto> registrar(
            @Valid @RequestBody IncidenciaCreateDto dto,
            Authentication auth) {
        Integer idFuncionario = (Integer) auth.getPrincipal();
        boolean isAdmin = esAdmin(auth);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(service.registrar(dto, idFuncionario, isAdmin, confidencial(auth)));

El formulario refresca sus permisos antes del envío. Spring autentica el token, verifica incidencias.crear y obtiene el funcionario registrante del principal. El DTO y el servicio validan los datos y comprueban el alcance sobre el estudiante.

4. Guardar la instancia y la incidencia con el mismo ID

IncidenciaService.java · líneas 51–72

Java
Instancia instancia = instanciaService.crear(
                "Incidencia",
                dto.getFecHora(),
                dto.getDescripcion(),
                idFuncionario,
                null,
                dto.getIdEstudiante(),
                dto.getLugar(),
                null,
                isAdmin,
                "incidencias.crear"
        );

        Incidencia i = new Incidencia();
        i.setInstancia(instancia);
        i.setLugar(dto.getLugar());
        i.setObservacionesConfidenciales(dto.getObservacionesConfidenciales());
        if (dto.isPersonasPresente()) {
            i.setPersonasRegistradas(true);
            i.setPersonasInvolucradas(new java.util.ArrayList<>(dto.getPersonasInvolucradas()));
        }
        i = repo.save(i);

El servicio crea primero una instancia titulada Incidencia, con la descripción general, la fecha, el lugar y las referencias al funcionario y al estudiante. La fila de incidencias usa @MapsId para compartir el ID de esa instancia. Las personas quedan en incidencia_personas con su orden y las observaciones quedan en la columna observaciones_confidenciales.

Los guardados participan en la misma transacción. El alta programa la auditoría para después del commit. El detalle de auditoría de la incidencia identifica al funcionario y la entidad, sin copiar el texto reservado.

5. Confirmar el guardado en pantalla

FormularioIncidencia.tsx · líneas 116–122

TypeScript
const creada = await crearIncidencia(body, permiteReserva, abortador.signal)
      if (!vigente() || abortador.signal.aborted) return
      setGuardada(creada)
      onGuardada?.(creada)
      setIdConfirmado(creada.idInstancia)
      idGuardada.current = creada.idInstancia
      setEstado('confirmado')

Con el 201 Created y un DTO válido, el formulario muestra el ID de la incidencia y el registrante. Después vuelve a leer el detalle. Si falla esa lectura, muestra una advertencia sobre la consulta aunque el alta ya esté confirmada. Si se corta el envío y el resultado queda incierto, la interfaz ofrece volver a la consulta para verificarlo.

Consultar el listado y el detalle

Sesión → permiso y alcance → listado → detalle autorizado por ID

1. Leer el listado según el alcance efectivo

HTTP · ID ficticio
GET /api/incidencias
GET /api/incidencias/701
GET /api/incidencias/mis-incidencias

IncidenciaService.java · líneas 126–133

Java
if (isAdmin) return repo.findAllConRelaciones();
        String[] permisos = java.util.stream.Stream.concat(java.util.stream.Stream.of("incidencias.leer"),
                java.util.Arrays.stream(permisosAdicionales)).toArray(String[]::new);
        Set<Integer> grupos = scopeService.getGruposPermitidos(actorId, false, permisos);
        if (grupos.isEmpty()) return List.of();
        Set<Integer> funcionarios = scopeService.getFuncionariosPermitidos(actorId, false, permisos);
        return repo.findAllConRelaciones().stream()
                .filter(i -> instanciaEnScope(i.getInstancia(), grupos, funcionarios)).toList();

El listado general pide incidencias.leer. Para cuentas con alcance acotado se aplican sus asignaciones efectivas. Para una incidencia con estudiante se comprueba su grupo. En una histórica sin estudiante se comprueba el funcionario de origen. Un administrador tiene alcance general en este listado.

2. Volver a autorizar al abrir un detalle por ID

IncidenciaService.java · líneas 91–100

Java
if (isAdmin) return IncidenciaResponseDto.from(i, confidencial);
        if (i.getInstancia().getFuncionario().getIdUsuario().equals(idSolicitante)) return IncidenciaResponseDto.from(i, confidencial);
        if (tieneLeer) {
            Set<Integer> grupos = scopeService.getGruposPermitidos(idSolicitante, false, "incidencias.leer");
            Set<Integer> funcionarios = scopeService.getFuncionariosPermitidos(idSolicitante, false, "incidencias.leer");
            if (!grupos.isEmpty() && instanciaEnScope(i.getInstancia(), grupos, funcionarios)) {
                return IncidenciaResponseDto.from(i, confidencial);
            }
        }
        throw new AccessDeniedException("La incidencia no pertenece a tu scope");

El detalle por ID admite al administrador o al funcionario que registró la incidencia. Para otra cuenta exige incidencias.leer y el alcance correspondiente. La ruta /mis-incidencias consulta las incidencias registradas por el funcionario autenticado. En los tres caminos, el permiso de observaciones se evalúa por separado.

React ordena el listado por fecha y abre el detalle con otra petición. Si la API rechaza el acceso con 403, la pantalla limpia la selección y refresca las capacidades.

Separar el registro general de la observación reservada

Acceso al registro + permiso confidencial → campos que entrega la API

1. Aplicar el permiso de confidencialidad al escribir

IncidenciaCreateDto.java · líneas 43–47

Java
public void validarConfidencial(boolean permitido) {
        if (confidencialPresente && !permitido)
            throw new org.springframework.security.access.AccessDeniedException("Se requiere incidencias.confidencial");
        if (observacionesConfidenciales!=null && observacionesConfidenciales.length()>4000)
            throw new IllegalArgumentException("observacionesConfidenciales supera 4000 caracteres");

La presencia del campo reservado exige incidencias.confidencial, incluso si el cuerpo envía null. En el PUT, omitirlo conserva el valor y null lo limpia con permiso. El servicio comprueba también la autorización para la operación y el acceso a esa incidencia.

La descripción general y las personas involucradas forman parte del registro ordinario. El permiso especial se aplica al campo observacionesConfidenciales.

2. Omitir las observaciones en el JSON cuando falta el permiso

IncidenciaResponseDto.java · líneas 39–41

Java
if (confidencial) dto.observacionesConfidenciales = i.getObservacionesConfidenciales();
        dto.personasInvolucradas=Boolean.TRUE.equals(i.getPersonasRegistradas()) ? java.util.List.copyOf(i.getPersonasInvolucradas()) : null;
        return dto;

El controlador calcula confidencial a partir de las autoridades de la cuenta. Si corresponde, el DTO incorpora la observación. Jackson omite la propiedad cuando su valor es null. La regla se aplica también a creadores y administradores.

Qué devuelve el detalle de la incidencia 701
Cuenta que consultaRespuesta de la API
Funcionario con lectura y alcance, sin permiso confidencial200 con los campos ordinarios.
Funcionario con el mismo alcance y permiso confidencial200 con el mismo registro y la observación reservada si está guardada.
Otro funcionario con lectura, fuera de alcance403 para el detalle de esa incidencia.

3. Volver a aplicar la reserva en otras vistas

incidenciasApi.ts · líneas 13–16

TypeScript
if (confidencial && valor.observacionesConfidenciales != null) {
    if (typeof valor.observacionesConfidenciales !== 'string') throw crearErrorDeRespuesta(new Error('Observaciones inválidas.'))
    respuesta.observacionesConfidenciales = valor.observacionesConfidenciales
  }

El frontend también condiciona la sección Confidencial a los permisos vigentes. Si pierde esa capacidad, vuelve a consultar o retira el dato del estado de pantalla según la vista. El límite principal sigue siendo el DTO que construye la API.

El historial aplica el mismo permiso al construir una fila de incidencia. Los reportes exigen acceso a reportes y lectura del origen. En el CSV de incidencias, la columna de observaciones confidenciales se agrega solo cuando se tiene incidencias.confidencial.

El campo reservado vive en la incidencia. El DTO de instancias propias puede incluir la descripción general de la instancia asociada. El permiso confidencial protege la observación reservada, no convierte toda la descripción en un dato reservado.

Ver la incidencia en el seguimiento del estudiante

Ficha accesible → fuentes autorizadas → combinación de filas → filtros y paginación

1. Consultar el historial desde la ficha

Ruta y HTTP · ID ficticio
/estudiantes/501/historial
GET /api/estudiantes/501/historial?estado=ACTIVOS&page=0&size=25

HistorialService.java · líneas 31–34

Java
List<String> fuentes=new ArrayList<>();
        for (String fuente:List.of("instancias","incidencias","recordatorios"))
            if (tiene(auth,fuente+".leer")) fuentes.add(fuente);
        if (fuentes.isEmpty()) throw new AccessDeniedException("Se requiere lectura de al menos un origen");

El backend comprueba estudiantes.leer y acceso a la ficha del estudiante. Después arma el conjunto de fuentes que el solicitante puede leer entre instancias, incidencias y recordatorios. Aplica el alcance de cada fuente al buscar los registros.

2. Combinar las fuentes sin duplicar el mismo hecho

HistorialService.java · líneas 37–46

Java
if (fuentes.contains("instancias")) {
            instancias.listarVisibles(actor,admin).stream()
                    .filter(i->i.getEstudiante()!=null && alumno.equals(i.getEstudiante().getIdUsuario()))
                    .forEach(i->eventos.put(i.getIdInstancia(),HistorialItemDto.instancia(i)));
        }
        if (fuentes.contains("incidencias")) {
            incidencias.listarVisibles(actor,admin).stream()
                    .filter(i->i.getInstancia().getEstudiante()!=null && alumno.equals(i.getInstancia().getEstudiante().getIdUsuario()))
                    .forEach(i->eventos.put(i.getIdInstancia(),HistorialItemDto.incidencia(i,tiene(auth,"incidencias.confidencial"))));
        }

La instancia y la incidencia asociada tienen el mismo ID. El mapa usa ese ID para que, si la incidencia está autorizada, su fila sustituya a la fila de instancia. Con acceso solo a la instancia queda la representación que el usuario puede leer. Los recordatorios se incorporan como otra clase de registro.

Los filtros de fechas usan días inclusivos en Montevideo. El servicio aplica también el filtro por estado, ordena por fecha descendente y pagina el resultado. La tabla y el detalle de React consumen esa respuesta.

Modificar o inactivar mediante la API

Permiso de la operación → acceso al registro → escritura → auditoría

1. Editar una incidencia desde el contrato de la API

HTTP · ejemplo ficticio
PUT /api/incidencias/701

{ "lugar": "Sala de seguimiento 2" }

IncidenciaService.java · líneas 150–153

Java
if (dto.has("lugar")) {
            i.setLugar(dto.getLugar());
            i.getInstancia().setLugar(dto.getLugar());
        }

El backend admite edición parcial. Exige incidencias.modificar. Para un funcionario que no es administrador también verifica que haya registrado la incidencia y que conserve el alcance efectivo. La incidencia debe estar activa. Si se envía el ID del estudiante, debe coincidir con el de la instancia.

Cuando cambia el lugar, el servicio actualiza ambos registros. También puede recibir cambios de fecha, descripción y personas, y aplica el permiso especial para las observaciones. En esta copia el recorrido de la web de incidencias ofrece alta y consulta. La edición y la baja de esta pestaña describen endpoints del backend.

2. Dar de baja de forma lógica

IncidenciaService.java · líneas 173–177

Java
public void desactivar(Integer id, Authentication auth) {
        Incidencia i = cargarParaModificar(id, auth, "incidencias.eliminar");
        if (!Boolean.TRUE.equals(i.getInstancia().getEstActivo())) return;
        i.getInstancia().setEstActivo(false);
        instanciaRepository.save(i.getInstancia());

Con incidencias.eliminar y el acceso a la operación, DELETE /api/incidencias/{id} cambia a inactiva la instancia asociada. La API responde 204. Si ya estaba inactiva, la operación vuelve a responder sin duplicar el cambio.

Se conservan los datos de la incidencia y sus relaciones. La auditoría se programa después del commit. En el historial, los filtros de estado determinan si aparece entre los resultados.

Para contarlo en la defensa

“Al registrar una incidencia, la API guarda una instancia y una incidencia que comparten ID. Valida al estudiante, el alcance y el permiso de quien registra. Para consultar, vuelve a decidir qué registros puede ver esa cuenta. Las observaciones confidenciales se incorporan al JSON solo con el permiso específico. En el historial, la incidencia autorizada ocupa el lugar de su instancia para que el mismo hecho aparezca una sola vez.”

Archivos revisados

Los fragmentos y números de línea corresponden a las copias del paquete del 06/11/2026.