+ * Updates an article, checking if the author is the same and if the new title's slug would be unique.
+ *
+ * @return the updated user.
+ */
+ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author author) throws IOException {
+
+ // Getting original article from slug
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ String id = articlePair.id();
+ Article oldArticle = articlePair.article();
+
+ // Checking if author is the same
+ if (!oldArticle.author().username().equals(author.username())) {
+ throw new UnauthorizedException("Cannot modify article from another author");
+ }
+
+ String newSlug = slug;
+ // If title is being changed, checking if new slug would be unique
+ if (!isNullOrBlank(article.title()) && !article.title().equals(oldArticle.title())) {
+ newSlug = generateAndCheckSlug(article.title());
+ }
+
+ Instant updatedAt = Instant.now();
+
+ // Null/blank check for every optional field
+ Article updatedArticle = new Article(newSlug,
+ isNullOrBlank(article.title()) ? oldArticle.title() : article.title(),
+ isNullOrBlank(article.description()) ? oldArticle.description() : article.description(),
+ isNullOrBlank(article.body()) ? oldArticle.body() : article.body(),
+ oldArticle.tagList(), oldArticle.createdAt(),
+ updatedAt, oldArticle.favorited(), oldArticle.favoritesCount(),
+ oldArticle.favoritedBy(), oldArticle.author());
+
+ updateArticle(id, updatedArticle);
+ return new ArticleDTO(updatedArticle);
+ }
+
+ /**
+ * Updates an article, given the updated object and its unique id.
+ */
+ private void updateArticle(String id, Article updatedArticle) throws IOException {
+ UpdateResponse
+ * Delete queries are very similar to search queries,
+ * here a term query (see {@link UserService#findUserByUsername(String)}) is used to match the
+ * correct article.
+ *
+ * The refresh value (see {@link ArticleService#newArticle(ArticleCreationDTO, Author)}) is
+ * set as "wait_for" for both delete queries, since the frontend will perform a get operation
+ * immediately after. The syntax for setting it as "wait_for" is different from the index operation,
+ * but the result is the same.
+ */
+ public void deleteArticle(String slug, Author author) throws IOException {
+
+ // Getting article from slug
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ Article article = articlePair.article();
+
+ // Checking if author is the same
+ if (!article.author().username().equals(author.username())) {
+ throw new UnauthorizedException("Cannot delete article from another author");
+ }
+
+ DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d
+ .index(ARTICLES)
+ .waitForCompletion(true)
+ .refresh(true)
+ .query(q -> q
+ .term(t -> t
+ .field("slug.keyword")
+ .value(slug))
+ ));
+ if (deleteArticle.deleted() < 1) {
+ throw new RuntimeException("Failed to delete article");
+ }
+
+ // Delete every comment to the article, using a term query
+ // that will match all comments with the same articleSlug
+ DeleteByQueryResponse deleteCommentsByArticle = esClient.deleteByQuery(d -> d
+ .index(COMMENTS)
+ .waitForCompletion(true)
+ .refresh(true)
+ .query(q -> q
+ .term(t -> t
+ .field("articleSlug.keyword")
+ .value(slug))
+ ));
+ }
+
+ /**
+ * Adds the requesting user to the article's favoritedBy list.
+ *
+ * @return the target article.
+ */
+ public Article markArticleAsFavorite(String slug, String username) throws IOException {
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ String id = articlePair.id();
+ Article article = articlePair.article();
+
+ // Checking if article was already marked as favorite
+ if (article.favoritedBy().contains(username)) {
+ return article;
+ }
+
+ article.favoritedBy().add(username);
+ Article updatedArticle = new Article(article.slug(), article.title(),
+ article.description(),
+ article.body(), article.tagList(), article.createdAt(), article.updatedAt(),
+ true, article.favoritesCount() + 1, article.favoritedBy(), article.author());
+
+ updateArticle(id, updatedArticle);
+ return updatedArticle;
+ }
+
+ /**
+ * Removes the requesting user from the article's favoritedBy list.
+ *
+ * @return the target article.
+ */
+ public Article removeArticleFromFavorite(String slug, String username) throws IOException {
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ String id = articlePair.id();
+ Article article = articlePair.article();
+
+ // Checking if article was not marked as favorite before
+ if (!article.favoritedBy().contains(username)) {
+ return article;
+ }
+
+ article.favoritedBy().remove(username);
+ int favoriteCount = article.favoritesCount() - 1;
+ boolean favorited = article.favorited();
+ if (favoriteCount == 0) {
+ favorited = false;
+ }
+
+ Article updatedArticle = new Article(article.slug(), article.title(),
+ article.description(),
+ article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited,
+ favoriteCount, article.favoritedBy(), article.author());
+
+ updateArticle(id, updatedArticle);
+ return updatedArticle;
+ }
+
+ /**
+ * Builds a search query using the filters the user is passing to retrieve all the matching articles.
+ *
+ * Since all the parameters are optional, the query must be build conditionally, adding one parameter
+ * at a time to the "conditions" array.
+ * Using a
+ * match
+ * query instead of a
+ * term
+ * query to allow the use of a single word for searching phrases,
+ * for example, filtering for articles with the "cat" tag will also return articles with the "cat food"
+ * tag.
+ *
+ * The articles are then sorted by the time they were last updated.
+ *
+ * @return a list containing all articles, filtered.
+ */
+ public ArticlesDTO findArticles(String tag, String author, String favorited, Integer limit,
+ Integer offset,
+ Optional
+ * The fields of the nested object "author" are easily accessible using the dot notation, for example
+ * "author.username".
+ *
+ * The articles are sorted by the time they were last updated.
+ *
+ * @return a list of articles from followed users.
+ */
+ public ArticlesDTO generateArticleFeed(User user) throws IOException {
+ // Preparing authors filter from user data
+ List
+ * The resulting list of tags is sorted by document count (how many times they appear in different
+ * documents).
+ *
+ * @return a list of all tags.
+ */
+ public TagsDTO findAllTags() throws IOException {
+
+ // If alphabetical order is preferred, use "_key" instead
+ NamedValue
+ * A boolean query similar to the one used in {@link UserService#newUser(RegisterDTO)} is used,
+ * matching both the comment id and the author's username, with a difference: here "must" is used
+ * instead of "should", meaning that the documents must match both conditions at the same time.
+ *
+ * @return The authenticated user.
+ */
+ public void deleteComment(String commentId, String username) throws IOException {
+
+ DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss
+ .index(COMMENTS)
+ .waitForCompletion(true)
+ .refresh(true)
+ .query(q -> q
+ .bool(b -> b
+ .must(m -> m
+ .term(t -> t
+ .field("id")
+ .value(commentId))
+ ).must(m -> m
+ .term(t -> t
+ .field("author.username.keyword")
+ .value(username))))
+ ));
+ if (deleteComment.deleted() < 1) {
+ throw new RuntimeException("Failed to delete comment");
+ }
+ }
+
+ /**
+ * Retrieves all comments with the same articleSlug value using a term query
+ * (see {@link UserService#findUserByUsername(String)}).
+ *
+ * @return a list of comment belonging to a single article.
+ */
+ public CommentsDTO findAllCommentsByArticle(String slug, Optional
+ * See {@link UserService#findUserByUsername(String)} for details on how the term query works.
+ *
+ * Combining multiple term queries into a single
+ * boolean query with "should" occur
+ * to match documents fulfilling either conditions to check the uniqueness of the new email and username.
+ *
+ * When the new user document is created, it is left up to elasticsearch to create a unique
+ * id field , since there's no user field that is guaranteed not to be updated/modified.
+ *
+ * @return The newly registered user.
+ */
+ public User newUser(RegisterDTO user) throws IOException {
+
+ // Checking uniqueness of both email and username
+ SearchResponse
+ * Updates a user, checking before if the new username or email would be unique.
+ *
+ * @return the updated user.
+ */
+ public User updateUser(UserDTO userDTO, String auth) throws IOException {
+
+ UserIdPair userPair = findUserByToken(auth);
+ User user = userPair.user();
+
+ // If the username or email are updated, checking uniqueness
+ if (!isNullOrBlank(userDTO.username()) && !userDTO.username().equals(user.username())) {
+ UserIdPair newUsernameSearch = findUserByUsername(userDTO.username());
+ if (Objects.nonNull(newUsernameSearch)) {
+ throw new ResourceAlreadyExistsException("Username already exists");
+ }
+ }
+
+ if (!isNullOrBlank(userDTO.email()) && !userDTO.email().equals(user.email())) {
+ UserIdPair newUsernameSearch = findUserByEmail(userDTO.username());
+ if (Objects.nonNull(newUsernameSearch)) {
+ throw new ResourceAlreadyExistsException("Email already in use");
+ }
+ }
+
+ // Null/blank check for every optional field
+ User updatedUser = new User(isNullOrBlank(userDTO.username()) ? user.username() :
+ userDTO.username(),
+ isNullOrBlank(userDTO.email()) ? user.email() : userDTO.email(),
+ user.password(), user.token(),
+ isNullOrBlank(userDTO.bio()) ? user.bio() : userDTO.bio(),
+ isNullOrBlank(userDTO.image()) ? user.image() : userDTO.image(),
+ user.salt(), user.following());
+
+ updateUser(userPair.id(), updatedUser);
+ return updatedUser;
+ }
+
+ /**
+ * Updates a user, given the updated object and its unique id.
+ */
+ private void updateUser(String id, User user) throws IOException {
+ UpdateResponse
+ * A
+ * term query
+ * means that it will find only results that match character by character.
+ *
+ * Using the
+ * keyword
+ * property of the field allows to use the original value of the string while querying, instead of the
+ * processed/tokenized value.
+ *
+ * @return a pair containing the result of the term query, a single user, with its id.
+ */
+ public UserIdPair findUserByUsername(String username) throws IOException {
+ // Simple term query to match exactly the username string
+ SearchResponse