From b18e21d4c73e7acd9b75b26e2215c153b0518828 Mon Sep 17 00:00:00 2001 From: Iurii Mednikov Date: Sun, 11 Jan 2026 13:01:00 +0100 Subject: [PATCH] Implemented follower logic --- .../config/IdentifierGeneratorConfig.java | 16 +++ .../domain/CreateFollowerRequestDto.java | 23 ++++ .../connections/domain/FollowerDto.java | 50 ++++++++ .../connections/domain/FollowerDtoMapper.java | 22 ++++ .../social/connections/domain/UserDto.java | 86 ++++++++++++++ .../connections/domain/UserDtoMapper.java | 25 ++++ .../FollowerAlreadyExistsException.java | 8 ++ .../exceptions/UserDoesNotExistException.java | 9 ++ .../social/connections/models/Follower.java | 112 ++++++++++++++++++ .../repositories/FollowerRepository.java | 14 +++ .../connections/services/FollowerService.java | 18 +++ .../services/FollowerServiceImpl.java | 73 ++++++++++++ .../web/FollowerRestController.java | 45 +++++++ .../resources/db/migration/V1__initial.sql | 14 +++ 14 files changed, 515 insertions(+) create mode 100644 src/main/java/dev/mednikov/social/connections/config/IdentifierGeneratorConfig.java create mode 100644 src/main/java/dev/mednikov/social/connections/domain/CreateFollowerRequestDto.java create mode 100644 src/main/java/dev/mednikov/social/connections/domain/FollowerDto.java create mode 100644 src/main/java/dev/mednikov/social/connections/domain/FollowerDtoMapper.java create mode 100644 src/main/java/dev/mednikov/social/connections/domain/UserDto.java create mode 100644 src/main/java/dev/mednikov/social/connections/domain/UserDtoMapper.java create mode 100644 src/main/java/dev/mednikov/social/connections/exceptions/FollowerAlreadyExistsException.java create mode 100644 src/main/java/dev/mednikov/social/connections/exceptions/UserDoesNotExistException.java create mode 100644 src/main/java/dev/mednikov/social/connections/models/Follower.java create mode 100644 src/main/java/dev/mednikov/social/connections/repositories/FollowerRepository.java create mode 100644 src/main/java/dev/mednikov/social/connections/services/FollowerService.java create mode 100644 src/main/java/dev/mednikov/social/connections/services/FollowerServiceImpl.java create mode 100644 src/main/java/dev/mednikov/social/connections/web/FollowerRestController.java diff --git a/src/main/java/dev/mednikov/social/connections/config/IdentifierGeneratorConfig.java b/src/main/java/dev/mednikov/social/connections/config/IdentifierGeneratorConfig.java new file mode 100644 index 0000000..e80c718 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/config/IdentifierGeneratorConfig.java @@ -0,0 +1,16 @@ +package dev.mednikov.social.connections.config; + +import dev.mednikov.social.connections.repositories.IdentifierGenerator; +import dev.mednikov.social.connections.repositories.IdentifierGeneratorImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class IdentifierGeneratorConfig { + + @Bean + public IdentifierGenerator identifierGenerator() { + return new IdentifierGeneratorImpl(); + } + +} diff --git a/src/main/java/dev/mednikov/social/connections/domain/CreateFollowerRequestDto.java b/src/main/java/dev/mednikov/social/connections/domain/CreateFollowerRequestDto.java new file mode 100644 index 0000000..75667e3 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/domain/CreateFollowerRequestDto.java @@ -0,0 +1,23 @@ +package dev.mednikov.social.connections.domain; + +public final class CreateFollowerRequestDto { + + private String ownerId; + private String followedUserId; + + public String getOwnerId() { + return ownerId; + } + + public void setOwnerId(String ownerId) { + this.ownerId = ownerId; + } + + public String getFollowedUserId() { + return followedUserId; + } + + public void setFollowedUserId(String followedUserId) { + this.followedUserId = followedUserId; + } +} diff --git a/src/main/java/dev/mednikov/social/connections/domain/FollowerDto.java b/src/main/java/dev/mednikov/social/connections/domain/FollowerDto.java new file mode 100644 index 0000000..8b09f22 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/domain/FollowerDto.java @@ -0,0 +1,50 @@ +package dev.mednikov.social.connections.domain; + +public final class FollowerDto { + + private String id; + private String ownerId; + private UserDto followedUser; + private boolean muted; + private boolean mutual; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOwnerId() { + return ownerId; + } + + public void setOwnerId(String ownerId) { + this.ownerId = ownerId; + } + + public UserDto getFollowedUser() { + return followedUser; + } + + public void setFollowedUser(UserDto followedUser) { + this.followedUser = followedUser; + } + + public boolean isMuted() { + return muted; + } + + public void setMuted(boolean muted) { + this.muted = muted; + } + + public boolean isMutual() { + return mutual; + } + + public void setMutual(boolean mutual) { + this.mutual = mutual; + } +} diff --git a/src/main/java/dev/mednikov/social/connections/domain/FollowerDtoMapper.java b/src/main/java/dev/mednikov/social/connections/domain/FollowerDtoMapper.java new file mode 100644 index 0000000..cb7d688 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/domain/FollowerDtoMapper.java @@ -0,0 +1,22 @@ +package dev.mednikov.social.connections.domain; + +import dev.mednikov.social.connections.models.Follower; + +import java.util.function.Function; + +public final class FollowerDtoMapper implements Function { + + private final static UserDtoMapper userDtoMapper = new UserDtoMapper(); + + @Override + public FollowerDto apply(Follower follower) { + FollowerDto result = new FollowerDto(); + result.setId(follower.getId().toString()); + result.setOwnerId(follower.getOwner().getId().toString()); + result.setFollowedUser(userDtoMapper.apply(follower.getFollowedUser())); + result.setMuted(follower.getMuted()); + result.setMutual(follower.getMutual()); + return result; + } + +} diff --git a/src/main/java/dev/mednikov/social/connections/domain/UserDto.java b/src/main/java/dev/mednikov/social/connections/domain/UserDto.java new file mode 100644 index 0000000..9c49bf5 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/domain/UserDto.java @@ -0,0 +1,86 @@ +package dev.mednikov.social.connections.domain; + +public final class UserDto { + + private String id; + private String firstName; + private String lastName; + private String email; + private boolean active; + private boolean onboarded; + private boolean emailVerified; + private String avatarUrl; + private String headline; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public boolean isOnboarded() { + return onboarded; + } + + public void setOnboarded(boolean onboarded) { + this.onboarded = onboarded; + } + + public boolean isEmailVerified() { + return emailVerified; + } + + public void setEmailVerified(boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getHeadline() { + return headline; + } + + public void setHeadline(String headline) { + this.headline = headline; + } +} diff --git a/src/main/java/dev/mednikov/social/connections/domain/UserDtoMapper.java b/src/main/java/dev/mednikov/social/connections/domain/UserDtoMapper.java new file mode 100644 index 0000000..58d4893 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/domain/UserDtoMapper.java @@ -0,0 +1,25 @@ +package dev.mednikov.social.connections.domain; + +import dev.mednikov.social.connections.models.User; + +import java.util.function.Function; + +public final class UserDtoMapper implements Function { + + @Override + public UserDto apply(User user) { + UserDto result = new UserDto(); + result.setId(user.getId().toString()); + result.setFirstName(user.getFirstName()); + result.setLastName(user.getLastName()); + result.setEmail(user.getEmail()); + result.setHeadline(user.getHeadline()); + result.setAvatarUrl(user.getAvatarUrl()); + result.setActive(user.getActive()); + result.setEmailVerified(user.getEmailVerified()); + result.setOnboarded(user.getOnboarded()); + return result; + } + + +} diff --git a/src/main/java/dev/mednikov/social/connections/exceptions/FollowerAlreadyExistsException.java b/src/main/java/dev/mednikov/social/connections/exceptions/FollowerAlreadyExistsException.java new file mode 100644 index 0000000..25bdec9 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/exceptions/FollowerAlreadyExistsException.java @@ -0,0 +1,8 @@ +package dev.mednikov.social.connections.exceptions; + +public class FollowerAlreadyExistsException extends ObjectAlreadyExistsException{ + + public FollowerAlreadyExistsException(Long ownerId, Long followedId) { + super("Follower between " + ownerId + " and " + followedId + " already exists"); + } +} diff --git a/src/main/java/dev/mednikov/social/connections/exceptions/UserDoesNotExistException.java b/src/main/java/dev/mednikov/social/connections/exceptions/UserDoesNotExistException.java new file mode 100644 index 0000000..b30dcca --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/exceptions/UserDoesNotExistException.java @@ -0,0 +1,9 @@ +package dev.mednikov.social.connections.exceptions; + +public class UserDoesNotExistException extends ObjectDoesNotExistException{ + + public UserDoesNotExistException(Long userId) { + super("User " + userId + " does not exist"); + } + +} diff --git a/src/main/java/dev/mednikov/social/connections/models/Follower.java b/src/main/java/dev/mednikov/social/connections/models/Follower.java new file mode 100644 index 0000000..4cc722a --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/models/Follower.java @@ -0,0 +1,112 @@ +package dev.mednikov.social.connections.models; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table( +name = "connections_follower", +uniqueConstraints = {@UniqueConstraint(columnNames = {"owner_id", "followed_user_id"})} +) +public class Follower { + + @Id + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User owner; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "followed_user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User followedUser; + + @Column(name = "is_mutual", nullable = false) + private Boolean mutual; + + @Column(name = "is_muted", nullable = false) + private Boolean muted; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "created_at") + @CreationTimestamp + private LocalDateTime createdAt; + + @Column(name = "updated_at") + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Override + public final boolean equals(Object o) { + if (!(o instanceof Follower follower)) return false; + + return owner.equals(follower.owner) + && followedUser.equals(follower.followedUser) + && version.equals(follower.version); + } + + @Override + public int hashCode() { + int result = owner.hashCode(); + result = 31 * result + followedUser.hashCode(); + result = 31 * result + version.hashCode(); + return result; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getOwner() { + return owner; + } + + public void setOwner(User owner) { + this.owner = owner; + } + + public User getFollowedUser() { + return followedUser; + } + + public void setFollowedUser(User followedUser) { + this.followedUser = followedUser; + } + + public Boolean getMutual() { + return mutual; + } + + public void setMutual(Boolean mutual) { + this.mutual = mutual; + } + + public Boolean getMuted() { + return muted; + } + + public void setMuted(Boolean muted) { + this.muted = muted; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } +} diff --git a/src/main/java/dev/mednikov/social/connections/repositories/FollowerRepository.java b/src/main/java/dev/mednikov/social/connections/repositories/FollowerRepository.java new file mode 100644 index 0000000..5bb4f0f --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/repositories/FollowerRepository.java @@ -0,0 +1,14 @@ +package dev.mednikov.social.connections.repositories; + +import dev.mednikov.social.connections.models.Follower; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface FollowerRepository extends JpaRepository { + + @Query("SELECT f FROM Follower f WHERE f.owner.id = :ownerId AND f.followedUser.id = :followedId") + Optional findForOwnerAndFollowed (Long ownerId, Long followedId); + +} diff --git a/src/main/java/dev/mednikov/social/connections/services/FollowerService.java b/src/main/java/dev/mednikov/social/connections/services/FollowerService.java new file mode 100644 index 0000000..cc37a1f --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/services/FollowerService.java @@ -0,0 +1,18 @@ +package dev.mednikov.social.connections.services; + +import dev.mednikov.social.connections.domain.CreateFollowerRequestDto; +import dev.mednikov.social.connections.domain.FollowerDto; + +import java.util.Optional; + +public interface FollowerService { + + FollowerDto createFollower(CreateFollowerRequestDto request); + + void deleteFollower (Long id); + + Optional findExistingFollower (Long ownerId, Long followedUserId); + + + +} diff --git a/src/main/java/dev/mednikov/social/connections/services/FollowerServiceImpl.java b/src/main/java/dev/mednikov/social/connections/services/FollowerServiceImpl.java new file mode 100644 index 0000000..0e1be70 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/services/FollowerServiceImpl.java @@ -0,0 +1,73 @@ +package dev.mednikov.social.connections.services; + +import dev.mednikov.social.connections.domain.CreateFollowerRequestDto; +import dev.mednikov.social.connections.domain.FollowerDto; +import dev.mednikov.social.connections.domain.FollowerDtoMapper; +import dev.mednikov.social.connections.exceptions.FollowerAlreadyExistsException; +import dev.mednikov.social.connections.exceptions.UserDoesNotExistException; +import dev.mednikov.social.connections.models.Follower; +import dev.mednikov.social.connections.models.User; +import dev.mednikov.social.connections.repositories.FollowerRepository; +import dev.mednikov.social.connections.repositories.IdentifierGenerator; +import dev.mednikov.social.connections.repositories.UserRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class FollowerServiceImpl implements FollowerService { + + private final static FollowerDtoMapper mapper = new FollowerDtoMapper(); + + private final FollowerRepository followerRepository; + private final UserRepository userRepository; + private final IdentifierGenerator identifierGenerator; + + public FollowerServiceImpl( + FollowerRepository followerRepository, + UserRepository userRepository, + IdentifierGenerator identifierGenerator) { + this.followerRepository = followerRepository; + this.userRepository = userRepository; + this.identifierGenerator = identifierGenerator; + } + + @Override + public Optional findExistingFollower(Long ownerId, Long followedUserId) { + return this.followerRepository + .findForOwnerAndFollowed(ownerId, followedUserId) + .map(mapper); + } + + @Override + public FollowerDto createFollower(CreateFollowerRequestDto request) { + Long ownerId = Long.parseLong(request.getOwnerId()); + Long followedUserId = Long.parseLong(request.getFollowedUserId()); + if (this.followerRepository.findForOwnerAndFollowed(ownerId, followedUserId).isPresent()) { + throw new FollowerAlreadyExistsException(ownerId, followedUserId); + } + + User owner = this.userRepository.findById(ownerId).orElseThrow(() -> + new UserDoesNotExistException(ownerId)); + User followedUser = this.userRepository.findById(followedUserId).orElseThrow(() -> + new UserDoesNotExistException(ownerId)); + + Follower follower = new Follower(); + follower.setOwner(owner); + follower.setFollowedUser(followedUser); + follower.setMuted(false); + follower.setMutual(false); + follower.setVersion(1L); + follower.setId(this.identifierGenerator.getNextId()); + + Follower savedFollower = this.followerRepository.save(follower); + + return mapper.apply(savedFollower); + } + + @Override + public void deleteFollower(Long id) { + this.followerRepository.deleteById(id); + } + +} diff --git a/src/main/java/dev/mednikov/social/connections/web/FollowerRestController.java b/src/main/java/dev/mednikov/social/connections/web/FollowerRestController.java new file mode 100644 index 0000000..af9faf3 --- /dev/null +++ b/src/main/java/dev/mednikov/social/connections/web/FollowerRestController.java @@ -0,0 +1,45 @@ +package dev.mednikov.social.connections.web; + +import dev.mednikov.social.connections.domain.CreateFollowerRequestDto; +import dev.mednikov.social.connections.domain.FollowerDto; +import dev.mednikov.social.connections.services.FollowerService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/followers") +public class FollowerRestController { + + private final FollowerService followerService; + + public FollowerRestController(FollowerService followerService) { + this.followerService = followerService; + } + + @GetMapping("/find") + public ResponseEntity findExistingFollower ( + @RequestParam(value = "followerId", required = true) String followerId, + @RequestParam(value = "followedId", required = true) String followedId + ) { + Long ownerId = Long.parseLong(followerId); + Long followedUserId = Long.parseLong(followedId); + Optional result = this.followerService.findExistingFollower(ownerId, followedUserId); + return ResponseEntity.of(result); + } + + @PostMapping("/create") + @ResponseStatus(HttpStatus.CREATED) + public @ResponseBody FollowerDto createFollower (@RequestBody CreateFollowerRequestDto body) { + return this.followerService.createFollower(body); + } + + @DeleteMapping("/delete/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteFollower (@PathVariable Long id){ + this.followerService.deleteFollower(id); + } + +} diff --git a/src/main/resources/db/migration/V1__initial.sql b/src/main/resources/db/migration/V1__initial.sql index be121e7..8f60bba 100644 --- a/src/main/resources/db/migration/V1__initial.sql +++ b/src/main/resources/db/migration/V1__initial.sql @@ -9,4 +9,18 @@ CREATE TABLE IF NOT EXISTS connections_user ( is_email_verified BOOLEAN NOT NULL, headline VARCHAR(255) NOT NULL, version BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS connections_follower ( + id BIGINT PRIMARY KEY, + owner_id BIGINT NOT NULL, + followed_user_id BIGINT NOT NULL, + is_mutual BOOLEAN NOT NULL, + is_muted BOOLEAN NOT NULL, + version BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (owner_id, followed_user_id), + CONSTRAINT fk_connections_follower_owner FOREIGN KEY (owner_id) REFERENCES connections_user(id) ON DELETE CASCADE, + CONSTRAINT fk_connections_follower_followed_user FOREIGN KEY (followed_user_id) REFERENCES connections_user(id) ON DELETE CASCADE ); \ No newline at end of file