Computación Web (2023/24)

Práctica 8: Desarrollo de una aplicación Spring (IV)

Respondiendo a mensajes

Hasta ahora, tu aplicación no permite responder a mensajes. En este ejercicio implementaremos esta nueva función en la vista de mensaje. Para no complicar en exceso la aplicación, no se permitirá responder a mensajes que sean respuestas a otros mensajes. Esto es, las respuestas no podrán ser anidadas.

En primer lugar, modifica la plantilla de la vista de mensaje añadiendo un nuevo formulario que permita responder al mensaje mostrado. Este formulario será casi igual que el de publicar nuevos mensajes en la vista principal, y también se enviará al mismo controlador, con la diferencia de que incluirá un control adicional de tipo hidden que especifica el identificador del mensaje al cual el usuario está respondiendo. Los controles ocultos no se muestran en el navegador, pero sus datos se envían junto con los datos de los otros controles del formulario cuando el usuario lo envía:

<input type="hidden" name="responseTo" th:value="${message.getId()}">

El código del controlador que programaste en el laboratorio anterior ya es capaz de recoger automáticamente estos datos al coincidir el nombre del control (responseTo) con la propiedad de la clase Message que tiene el mismo nombre.

Por lo tanto, si el mensaje que estás creando no es una respuesta a otro mensaje, su propiedad responseTo tendrá un valor null. De lo contrario, si es una respuesta a otro mensaje, ese otro mensaje estará disponible en su propiedad responseTo.

Por último, debes modificar la redirección al final del controlador:

Para comprobar que las respuestas se están almacenando correctamente en la base de datos, conéctate a tu base de datos y verifica que los mensajes de respuesta que creas estén allí y tengan el identificador correcto en su columna response_to_id.

Mostrando mensajes de respuesta

En este momento, la vista de mensaje muestra un mensaje sin sus respuestas. Modifícala para que las respuestas, si las hay, se muestren, ordenadas de más recientes a menos recientes.

Para obtener la lista ordenada de respuestas, puedes declarar el siguiente método en la interfaz MessageRepository:

List<Message> findByResponseToOrderByTimestampAsc(Message message);

Nuevamente, el entorno de Spring Data JPA derivará automáticamente la implementación de este método a partir de su nombre.

Ocultando mensajes de respuesta en algunas vistas

Dado que los mensajes de respuesta son ciudadanos de segunda clase en nuestra aplicación, no queremos que se muestren en la vista principal ni en la vista de usuario. Además, no queremos mostrar la vista de mensaje para los mensajes de respuesta.

Aplica los siguientes cambios:

  1. En la vista principal, obtén los 10 mensajes más recientes que no sean respuestas. Puedes declarar y utilizar el siguiente método nuevo en la interfaz MessageRepository:
    List<Message> findFirst10ByResponseToIsNullOrderByTimestampDesc();
  2. En la vista del perfil de usuario, obtén solo aquellos mensajes que no sean respuestas. Puedes declarar y utilizar el siguiente método nuevo en la interfaz MessageRepository:
    List<Message> findByUserAndResponseToIsNullOrderByTimestampDesc(User user);
  3. En la vista de mensaje, aborta con un error 403 (Prohibido) si el mensaje a mostrar es un mensaje de respuesta.

Los usuarios pueden seguir a otros usuarios

Implementemos ahora una nueva funcionalidad para que los usuarios puedan seguir a otros usuarios de la aplicación.

El primer paso consiste en actualizar el modelo de la base de datos. Dado que un usuario puede seguir a muchos usuarios y ser seguido por muchos usuarios también, hay una relación de muchos a muchos desde la tabla de usuarios hacia sí misma. Las relaciones de muchos a muchos se modelan en JPA con la anotación @ManyToMany. Agrega los siguientes dos atributos a tu clase User:

// Nuevas clases a importar:
import jakarta.persistence.ManyToMany;
import jakarta.persistence.JoinTable;

// Declaraciones de los nuevos atributos:
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name="following",
    joinColumns=@JoinColumn(name="follower_id"),
    inverseJoinColumns=@JoinColumn(name="followed_id"))
private List<User> following;

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name="following",
    joinColumns=@JoinColumn(name="followed_id"),
    inverseJoinColumns=@JoinColumn(name="follower_id"))
private List<User> followers;

Recuerda también programar los métodos getter y setter para estos dos atributos.

El atributo following almacenará la lista de usuarios a los que este usuario sigue. El atributo followers almacenará la lista de usuarios que siguen a este usuario.

Ambos atributos están anotados como lazy, lo que significa que estas listas solo se cargarán desde la base de datos si tu programa accede a ellas.

Las relaciones de muchos a muchos se modelan a través de una tabla intermedia. En este caso, esa tabla consta de dos columnas: el id del seguidor y el id del seguido. Ambos son claves ajenas que hacen referencia a filas en la tabla de usuarios. Además, la combinación de ambas columnas forma la clave primaria de la tabla.

La anotación @JoinTable que estamos utilizando le indica al sistema de persistencia que ambas relaciones se basan en esa tabla intermedia llamada following. Contendrá dos columnas, una para almacenar el id del usuario seguido (llamada followed_id) y la otra para almacenar el id del usuario seguidor (llamada follower_id).

La lista de usuarios a los que sigue este usuario se construye haciendo coincidir el id del usuario con la columna follower_id de la tabla intermedia (es decir, este usuario es el seguidor de la relación). Por esa razón, declaramos follower_id como la columna de unión en este caso. La lista de usuarios que siguen a este usuario se construye haciendo coincidir la otra columna, y por eso declaramos followed_id como la columna de unión en este otro caso.

Por último, debes permitir que Spring Data JPA actualice tu base de datos. Reinicia tu aplicación y la nueva tabla se creará automáticamente.

Comprueba que haya aparecido en la base de datos una nueva tabla llamada following. Examina qué columnas contiene.

El modelo está completo ahora. En el próximo ejercicio aprenderás cómo utilizar estos dos nuevos atributos para que los usuarios sigan o dejen de seguir a otros usuarios.

Un servicio para seguir y dejar de seguir a usuarios

Dado que las operaciones de seguir y dejar de seguir tienen cierta complejidad, principalmente debido a las comprobaciones de datos que serán necesarias, moveremos su implementación a un servicio. En particular, extenderemos la interfaz UserService y su implementación UserServiceImpl, que ya tienes en tu proyecto.

Primero, crea una nueva clase de excepción personalizada llamada UserServiceException en el paquete es.uc3m.microblog.services:

package es.uc3m.microblog.services;

public class UserServiceException extends Exception {

    private static final long serialVersionUID = 1L;

    public UserServiceException() {
    }

    public UserServiceException(String message) {
        super(message);
    }
}

A continuación, agrega los siguientes tres métodos a la interfaz UserService:

boolean follows(User follower, User followed);
void follow(User follower, User followed) throws UserServiceException;
void unfollow(User follower, User followed) throws UserServiceException;

Los utilizaremos para hacer que un usuario empiece a seguir a otro usuario, para que deje de seguir a otro usuario y para verificar si un usuario está siguiendo actualmente a otro.

Ahora, necesitamos programar estos métodos en la clase UserServiceImpl:

Comprobarás esta funcionalidad más adelante, cuando avances más con su implementación.

Controladores para seguir y dejar de seguir a usuarios

Ahora que la base de datos permite representar la relación de seguidor / seguido entre usuarios y que tenemos un servicio para seguir y dejar de seguir, terminemos la funcionalidad que permite a los usuarios, cuando visitan el perfil público de otro usuario, seguirlo o dejar de seguirlo.

Primero, programa un nuevo método controlador en MainController. Hará que el usuario asociado a la sesión actual siga a otro usuario. Este último se recibirá como un parámetro en la URL:

@PostMapping(path = "/follow/{userId}")
public String follow(@PathVariable("userId") int followedUserId, Principal principal) {
    (...)
}

Este controlador usará el servicio de usuario para hacer que el usuario autenticado siga al usuario cuyo id recibe. Necesita obtener este último de la base de datos (y devolver un error 404 No Encontrado si no está allí) y usar el método follow del servicio. Si ese método lanza una excepción UserServiceException, el método del controlador debería devolver un error 403 Prohibido. Cuando todo esté hecho, el controlador debería devolver una respuesta de redirección a la página de perfil del usuario a seguir.

Programa otro controlador para dejar de seguir a un usuario. Será bastante similar al que acabas de programar.

Aún no puedes comprobar estos dos nuevos controladores. Para eso, necesitas añadir un formulario para seguir o dejar de seguir a un usuario en la vista del perfil de usuario. Harás eso en el próximo ejercicio.

Formularios para seguir y dejar de seguir a usuarios

En primer lugar, modifica el controlador de la vista del perfil de usuario para que envíe un nuevo parámetro llamado, por ejemplo, followButton a la plantilla. Será una cadena que tomará los valores "none" (cuando el usuario actual y el usuario mostrado son el mismo), "follow" (cuando el usuario actual todavía no está siguiendo al usuario mostrado en esa vista de perfil) y "unfollow" (cuando el usuario actual ya está siguiendo al usuario mostrado en esa vista de perfil).

Con ese parámetro, la vista sabe ahora qué formulario mostrar: un formulario para seguir, un formulario para dejar de seguir o ningún formulario. Esos formularios solo necesitan un control, que será un botón de envío con el texto apropiado. Se verían así (es posible que necesites ajustar cosas, como las URL de acción o el nombre de la variable que contiene el usuario cuyo perfil se está mostrando):

<form th:if="${followButton.equals('follow')}"
    th:action="@{/follow/{user_id}(user_id=${user.getId()})}" method="post">
    <input type="submit" value="Follow">
</form>
<form th:if="${followButton.equals('unfollow')}"
    th:action="@{/unfollow/{user_id}(user_id=${user.getId()})}" method="post">
    <input type="submit" value="Unfollow">
</form>

Observa cómo la URL del controlador de destino se construye a partir del identificador del usuario cuyo perfil se está mostrando. Observa también que el formulario se enviará con una solicitud POST, ya que es lo que esperan los métodos del controlador.

Ahora puedes probar las funciones de seguir y dejar de seguir. Conéctate también a la base de datos para asegurarte de que todas las acciones de seguir y dejar de seguir que hagas en la aplicación se guarden correctamente en la tabla following.

Mostrar mensajes de los usuarios a quien se sigue en la vista principal

Para finalizar la funcionalidad de seguir / dejar de seguir, necesitamos modificar la consulta en el controlador de la vista principal para que obtenga, en lugar de los 10 mensajes más recientes de cualquier usuario, los 10 mensajes más recientes de los usuarios a los que el usuario actual está siguiendo.

Una forma de obtener estos mensajes habría sido añadir la siguiente declaración de método a MessageRepository:

List<Message> findFirst10ByUserInOrderByTimestampDesc(List<User> users);

La cláusula byUserIn hace que la consulta solo seleccione mensajes de los usuarios proporcionados en la lista que recibe el método. Si esa lista contiene a los usuarios a los que sigue el usuario actual, resuelve la tarea en cuestión. Sin embargo, con esta solución estamos haciendo dos consultas a la base de datos: una para obtener esa lista de usuarios seguidos y otra para obtener los mensajes.

Una forma de resolver este problema con solo una consulta sería combinar tres tablas con INNER JOIN: tabla de usuarios consigo misma (para obtener los usuarios seguidos) y luego con mensajes (para obtener los mensajes en sí mismos). Sin embargo, no podemos expresar esto con la interpolación de métodos. En su lugar, podemos proporcionar la consulta en Jakarta Persistence Query Language (JPQL), anteriormente conocido como Java Persistence Query Language. Este lenguaje está basado en SQL pero, a diferencia de SQL, es portable a cualquier sistema de gestión de bases de datos relacionales compatible.

Resolvamos el problema de esta otra manera. Primero, añade la siguiente declaración a MessageRepository:

// Nuevas clases a importar:
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

// Nueva declaración de método:
@Query("SELECT messages "
      + "FROM User user "
      + "JOIN user.following followed "
      + "JOIN followed.messages messages "
      + "WHERE user=?1 AND messages.responseTo IS NULL "
      + "ORDER BY messages.timestamp DESC")
List<Message> messagesFromFollowedUsers(User user, Pageable pageable);

Fíjate en que:

Ahora, debes ir al método del controlador para la vista principal y cambiar la forma en que obtienes los mensajes a mostrar allí:

// Nuevas clases a importar:
import org.springframework.data.domain.PageRequest;

// Llamada para obtener la lista de mensajes:
List<Message> messages = messageRepository.messagesFromFollowedUsers(user, PageRequest.of(0, 10));
            

La llamada PageRequest.of(0, 10) hace que la consulta devuelva los primeros 10 mensajes. Su primer parámetro es el número de página que necesitas (la primera página es la número 0) y el segundo es el número de resultados por página (10 en este caso). Entonces, obtendremos resultados del 1 al 10. Por ejemplo, si hubiéramos querido obtener resultados del 11 al 20 habríamos usado PageRequest.of(1, 10).

Comprueba ahora que la vista funciona y muestra los mensajes correctos.