Skip to content

Commit

Permalink
friends endpoint for habits to track if user has sent invite already (#…
Browse files Browse the repository at this point in the history
…7911)

* friends endpoint for habits to track if user has sent invite already

* checkstyle + formatter

* controller test

* tests

* tests for new code

* more test coverage

* more test coverage

* more test coverage

* javadocs, test coverage, formatter, checkstyle

* spacing in javadoc comment

* sonar duplicate code, refactore, super builder instead of builder

* unused imprts

* issues

* delete unused method

* ref code to get has invitation not by calling each friend separately

* test coverage on new code

* ref + test

* formatter + checkstyle

* issues

* small issues with unused import 🤏
  • Loading branch information
holotsvan authored Dec 12, 2024
1 parent d251cf9 commit 9bc1a2f
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 14 deletions.
32 changes: 32 additions & 0 deletions core/src/main/java/greencity/controller/HabitController.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import greencity.constant.HttpStatuses;
import greencity.constant.SwaggerExampleModel;
import greencity.dto.PageableDto;
import greencity.dto.friends.UserFriendHabitInviteDto;
import greencity.dto.habit.*;
import greencity.dto.todolistitem.ToDoListItemDto;
import greencity.dto.habittranslation.HabitTranslationDto;
Expand All @@ -33,6 +34,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -509,4 +511,34 @@ public ResponseEntity<PageableDto<HabitDto>> getAllFavorites(
return ResponseEntity.status(HttpStatus.OK).body(
habitService.getAllFavoriteHabitsByLanguageCode(userVO, pageable, locale.getLanguage()));
}

/**
* Retrieves a paginated list of friends who can be invited to a specific habit.
* Optionally filters by friend name.
*
* @param page The pagination information (page number, size).
* @param name Optional name filter for friends.
* @param habitId The ID of the habit for which friends are being invited.
* @param userVO The current user's details.
* @return A paginated list of friends (UserFriendHabitInviteDto) to be invited.
*/
@Operation(summary = "Find all friends to be invited")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = HttpStatuses.OK),
@ApiResponse(responseCode = "400", description = HttpStatuses.BAD_REQUEST,
content = @Content(examples = @ExampleObject(HttpStatuses.BAD_REQUEST))),
@ApiResponse(responseCode = "401", description = HttpStatuses.UNAUTHORIZED,
content = @Content(examples = @ExampleObject(HttpStatuses.UNAUTHORIZED)))
})
@GetMapping("/friends")
@ApiPageable
public ResponseEntity<PageableDto<UserFriendHabitInviteDto>> findAllFriendsOfUserToBeInvited(
@Parameter(hidden = true) Pageable page,
@RequestParam(required = false) @Nullable String name,
@RequestParam Long habitId,
@Parameter(hidden = true) @CurrentUser UserVO userVO) {
return ResponseEntity
.status(HttpStatus.OK)
.body(habitService.findAllFriendsOfUser(userVO, name, page, habitId));
}
}
61 changes: 49 additions & 12 deletions core/src/test/java/greencity/controller/HabitControllerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@
import greencity.dto.habit.CustomHabitDtoRequest;
import greencity.dto.user.UserVO;
import greencity.exception.handler.CustomExceptionHandler;
import greencity.repository.HabitTranslationRepo;
import greencity.service.HabitService;
import java.security.Principal;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import greencity.service.TagsService;
import greencity.service.UserService;
import lombok.SneakyThrows;
Expand All @@ -21,7 +17,6 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.data.domain.PageRequest;
Expand All @@ -30,17 +25,22 @@
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.Validator;
import java.security.Principal;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import static greencity.ModelUtils.getPrincipal;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.validation.Validator;

@ExtendWith(MockitoExtension.class)
class HabitControllerTest {
Expand All @@ -62,6 +62,9 @@ class HabitControllerTest {
@Mock
private Validator mockValidator;

@Mock
private HabitTranslationRepo habitTranslationRepo;

private static final String habitLink = "/habit";

private final Principal principal = getPrincipal();
Expand Down Expand Up @@ -456,4 +459,38 @@ void removeFromFavoritesTest() {
.andExpect(status().isOk());
verify(habitService).removeFromFavorites(habitId, principal.getName());
}

@Test
@SneakyThrows
void getAllFavoritesTest() {
UserVO userVO = new UserVO();
Pageable pageable = PageRequest.of(0, 10);
String languageCode = "en";

mockMvc.perform(get(habitLink + "/favorites")
.principal(getPrincipal())
.param("page", "0")
.param("size", "10")
.locale(Locale.forLanguageTag(languageCode)))
.andExpect(status().isOk());

verify(habitService).getAllFavoriteHabitsByLanguageCode(userVO, pageable, languageCode);
}

@Test
@SneakyThrows
void findAllFriendsOfUserToBeInvitedTest() {
Long habitId = 1L;
Pageable pageable = PageRequest.of(0, 10);
UserVO userVO = new UserVO();

mockMvc.perform(get(habitLink + "/friends")
.principal(getPrincipal())
.param("habitId", habitId.toString())
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk());

verify(habitService).findAllFriendsOfUser(userVO, null, pageable, habitId);
}
}
58 changes: 58 additions & 0 deletions dao/src/main/java/greencity/repository/HabitInvitationRepo.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package greencity.repository;

import greencity.dto.friends.UserFriendHabitInviteDto;
import greencity.entity.HabitAssign;
import greencity.entity.HabitInvitation;
import jakarta.persistence.Tuple;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;

Expand Down Expand Up @@ -35,4 +40,57 @@ public interface HabitInvitationRepo extends JpaRepository<HabitInvitation, Long
boolean existsByInviteeHabitAssign(HabitAssign inviteeHabitAssign);

boolean existsByInviterHabitAssignAndInviteeHabitAssign(HabitAssign inviterHabitAssign, HabitAssign habitAssign);

/**
* Retrieves a paginated list of a user's friends with optional name filtering.
* Each friend is represented as a {@link UserFriendHabitInviteDto}, including a
* {@code hasInvitation} flag indicating if the friend has a pending habit
* invitation for the specified habit. Friends without invitations will have
* {@code hasInvitation} set to {@code false}.
*
* @param userId the ID of the user whose friends are retrieved.
* @param name an optional case-insensitive name filter.
* @param habitId the ID of the habit to check for pending invitations.
* @param pageable pagination information for the result.
* @return a {@link Page} of {@link UserFriendHabitInviteDto}.
*/
@Query(nativeQuery = true, value = """
WITH friends AS (
SELECT DISTINCT user_id AS id
FROM users_friends
WHERE friend_id = :userId AND status = 'FRIEND'
UNION
SELECT friend_id AS id
FROM users_friends
WHERE user_id = :userId AND status = 'FRIEND'
),
filtered_friends AS (
SELECT u.id,
u.name,
u.email,
u.profile_picture
FROM users u
WHERE u.id IN (SELECT id FROM friends)
AND LOWER(u.name) LIKE LOWER(CONCAT('%', :name, '%'))
),
invitations AS (
SELECT DISTINCT i.invitee_id AS friend_id,
TRUE AS has_invitation
FROM habit_invitations i
WHERE i.status = 'PENDING'
AND i.inviter_id = :userId
AND i.inviter_habit_assign_id IN (
SELECT ha.id FROM habit_assign ha WHERE ha.habit_id = :habitId
)
)
SELECT f.id,
f.name,
f.email,
f.profile_picture,
COALESCE(inv.has_invitation, FALSE) AS has_invitation
FROM filtered_friends f
LEFT JOIN invitations inv ON f.id = inv.friend_id
""")
List<Tuple> findUserFriendsWithHabitInvites(
Long userId, String name, Long habitId, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import greencity.dto.location.UserLocationDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@NoArgsConstructor
@AllArgsConstructor
@Builder
@SuperBuilder
@Data
@SuppressWarnings("java:S107")
public class UserFriendDto {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package greencity.dto.friends;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class UserFriendHabitInviteDto extends UserFriendDto {
private Boolean hasInvitation;
}
16 changes: 16 additions & 0 deletions service-api/src/main/java/greencity/service/HabitService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package greencity.service;

import greencity.dto.PageableDto;
import greencity.dto.friends.UserFriendHabitInviteDto;
import greencity.dto.habit.CustomHabitDtoRequest;
import greencity.dto.habit.CustomHabitDtoResponse;
import greencity.dto.habit.HabitVO;
Expand All @@ -9,6 +10,7 @@
import greencity.dto.user.UserProfilePictureDto;
import greencity.dto.user.UserVO;
import org.springframework.data.domain.Pageable;
import org.springframework.lang.Nullable;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -228,4 +230,18 @@ CustomHabitDtoResponse updateCustomHabit(CustomHabitDtoRequest customHabitDtoReq
* @return Pageable of {@link HabitDto}.
*/
PageableDto<HabitDto> getAllFavoriteHabitsByLanguageCode(UserVO userVO, Pageable pageable, String languageCode);

/**
* Retrieves a paginated list of friends of a user with has invitation status.
* Optionally filters by friend name.
*
* @param userVO The current user's details.
* @param name Optional name filter for friends.
* @param pageable .
* @param habitId The ID of the habit.
* @return A paginated list of friends (UserFriendHabitInviteDto) who can be
* invited to the habit.
*/
PageableDto<UserFriendHabitInviteDto> findAllFriendsOfUser(UserVO userVO, @Nullable String name,
Pageable pageable, Long habitId);
}
38 changes: 38 additions & 0 deletions service/src/main/java/greencity/service/HabitServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import greencity.constant.ErrorMessage;
import greencity.dto.PageableDto;
import greencity.dto.filter.HabitTranslationFilterDto;
import greencity.dto.friends.UserFriendHabitInviteDto;
import greencity.dto.habit.CustomHabitDtoRequest;
import greencity.dto.habit.CustomHabitDtoResponse;
import greencity.dto.habit.HabitDto;
Expand Down Expand Up @@ -35,6 +36,7 @@
import greencity.mapping.HabitTranslationDtoMapper;
import greencity.mapping.HabitTranslationMapper;
import greencity.rating.RatingCalculation;
import greencity.repository.HabitInvitationRepo;
import greencity.repository.HabitRepo;
import greencity.repository.HabitTranslationRepo;
import greencity.repository.ToDoListItemTranslationRepo;
Expand All @@ -51,10 +53,12 @@
import greencity.repository.TagsRepo;
import greencity.repository.UserRepo;
import greencity.repository.options.HabitTranslationFilter;
import jakarta.persistence.Tuple;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.modelmapper.ModelMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -90,6 +94,8 @@ public class HabitServiceImpl implements HabitService {
private final AchievementCalculation achievementCalculation;
private final RatingPointsRepo ratingPointsRepo;
private final HabitInvitationService habitInvitationService;
private final FriendService friendService;
private final HabitInvitationRepo habitInvitationRepo;

/**
* Method returns Habit by its id.
Expand Down Expand Up @@ -680,6 +686,23 @@ public PageableDto<HabitDto> getAllFavoriteHabitsByLanguageCode(UserVO userVO, P
return buildPageableDtoForDifferentParameters(habitTranslationPage, userVO.getId());
}

/**
* {@inheritDoc}
*/
@Override
public PageableDto<UserFriendHabitInviteDto> findAllFriendsOfUser(UserVO userVO, String name, Pageable pageable,
Long habitId) {
Long userId = userVO.getId();
name = Optional.ofNullable(name).orElse("");
Page<UserFriendHabitInviteDto> friendsWithIsInvitedStatus =
findUserFriendsWithHabitInvitesMapped(userId, name, habitId, pageable);
return new PageableDto<>(
friendsWithIsInvitedStatus.getContent(),
friendsWithIsInvitedStatus.getTotalElements(),
friendsWithIsInvitedStatus.getNumber(),
friendsWithIsInvitedStatus.getTotalPages());
}

private boolean isCurrentUserFollower(Habit habit, Long currentUserId) {
return habit.getFollowers().stream()
.anyMatch(user -> user.getId().equals(currentUserId));
Expand Down Expand Up @@ -733,4 +756,19 @@ private boolean removeDislikeIfExists(Habit habit, UserVO userVO) {
}
return false;
}

private Page<UserFriendHabitInviteDto> findUserFriendsWithHabitInvitesMapped(
Long userId, String name, Long habitId, Pageable pageable) {
List<Tuple> tuples = habitInvitationRepo.findUserFriendsWithHabitInvites(userId, name, habitId, pageable);
List<UserFriendHabitInviteDto> dtoList = tuples.stream()
.map(tuple -> UserFriendHabitInviteDto.builder()
.id(tuple.get("id", Long.class))
.name(tuple.get("name", String.class))
.email(tuple.get("email", String.class))
.profilePicturePath(tuple.get("profile_picture", String.class))
.hasInvitation(tuple.get("has_invitation", Boolean.class))
.build())
.collect(Collectors.toList());
return new PageImpl<>(dtoList, pageable, dtoList.size());
}
}
Loading

0 comments on commit 9bc1a2f

Please sign in to comment.