diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/enums/FriendshipsStatus.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/enums/FriendshipsStatus.java new file mode 100644 index 0000000..ef9f7ed --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/enums/FriendshipsStatus.java @@ -0,0 +1,7 @@ +package ru.practicum.ewm.enums; + +public enum FriendshipsStatus { + NO_FRIENDSHIP, + ONE_SIDED, + MUTUAL +} diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/event/EventRepository.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/event/EventRepository.java index 9427c6c..8d263e2 100644 --- a/ewm-main-svc/src/main/java/ru/practicum/ewm/event/EventRepository.java +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/event/EventRepository.java @@ -5,8 +5,8 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import ru.practicum.ewm.enums.EventState; import ru.practicum.ewm.event.model.Event; +import ru.practicum.ewm.enums.EventState; import java.time.LocalDateTime; import java.util.List; @@ -31,13 +31,15 @@ List findAllByAdmin( Pageable pageable ); - List findAllByInitiatorId(Long initiatorId, Pageable pageable); + List findAllByInitiatorId(Long initiatorId, Pageable pageable); + + List findAllByIdIn(List eventIds); - List findAllByIdIn(List eventIds); + boolean existsByCategoryId(Long id); - boolean existsByCategoryId(Long id); + Optional findByIdAndInitiatorId(Long eventId, Long userId); - Optional findByIdAndInitiatorId(Long eventId, Long userId); + Page findAll(Specification spec, Pageable pageable); - Page findAll(Specification spec, Pageable pageable); - } \ No newline at end of file + List findByInitiatorIdIn(List initiatorIds, Pageable pageable); +} \ No newline at end of file diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/SubscriptionRepository.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/SubscriptionRepository.java new file mode 100644 index 0000000..dc3b9d8 --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/SubscriptionRepository.java @@ -0,0 +1,20 @@ +package ru.practicum.ewm.subscription; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import ru.practicum.ewm.enums.FriendshipsStatus; +import ru.practicum.ewm.subscription.model.Subscription; +import ru.practicum.ewm.user.model.User; + +import java.util.List; +import java.util.Optional; + +public interface SubscriptionRepository extends JpaRepository { + Optional findByFollowerAndOwner(User follower, User owner); + + List findByFollower(User follower); + + List findByOwner(User owner, Pageable pageable); + + long countByOwnerAndFriendshipsStatusIn(User owner, List friendshipsStatuses); +} diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/controller/SubscriptionController.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/controller/SubscriptionController.java new file mode 100644 index 0000000..b1870d8 --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/controller/SubscriptionController.java @@ -0,0 +1,67 @@ +package ru.practicum.ewm.subscription.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.ewm.event.dto.EventShortDto; +import ru.practicum.ewm.subscription.dto.NewRequestSubscription; +import ru.practicum.ewm.subscription.dto.SubscriberData; +import ru.practicum.ewm.subscription.dto.SubscriptionDto; +import ru.practicum.ewm.subscription.service.SubscriptionService; + +import java.util.List; + +@RestController +@RequestMapping("/users/{userId}/subscriptions") +@RequiredArgsConstructor +@Slf4j +@Validated +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class SubscriptionController { + + SubscriptionService subscriptionService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public SubscriptionDto subscribe(@PathVariable Long userId, + @Valid @RequestBody NewRequestSubscription requestSubscription) { + log.info("Получен POST запрос на подписку пользователя {} на пользователя {}", userId, requestSubscription.getOwnerId()); + return subscriptionService.subscribe(userId, requestSubscription); + } + + @DeleteMapping("/{ownerId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unsubscribe(@PathVariable Long userId, @PathVariable Long ownerId) { + log.info("Получен DELETE запрос на отписку пользователя {} от пользователя {}", userId, ownerId); + subscriptionService.unsubscribe(userId, ownerId); + } + + @GetMapping("/events") + public List getEventsFromSubscriptions(@PathVariable Long userId, + @RequestParam(defaultValue = "0") @PositiveOrZero int from, + @RequestParam(defaultValue = "10") @Positive int size) { + log.info("Получен GET запрос на получение событий от подписок пользователя {}", userId); + return subscriptionService.getEventsFromSubscriptions(userId, from, size); + } + + @GetMapping("/subscribers/count") + public Long getSubscriberCount(@PathVariable Long userId) { + log.info("Получен GET запрос на получение количества подписчиков пользователя {}",userId); + return subscriptionService.getSubscriberCount(userId); + } + + @GetMapping("/subscribers") + public List getAllSubscribers(@PathVariable Long userId, + @RequestParam(defaultValue = "0") @PositiveOrZero int from, + @RequestParam(defaultValue = "10") @Positive int size) { + log.info("Получен GET запрос на получение всех подписчиков пользователя {}", userId); + return subscriptionService.getAllSubscribers(userId, from, size); + } +} \ No newline at end of file diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/NewRequestSubscription.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/NewRequestSubscription.java new file mode 100644 index 0000000..a197e9f --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/NewRequestSubscription.java @@ -0,0 +1,16 @@ +package ru.practicum.ewm.subscription.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.FieldDefaults; + +@FieldDefaults(level = AccessLevel.PRIVATE) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NewRequestSubscription { + + @NotNull + Long ownerId; +} diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/SubscriberData.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/SubscriberData.java new file mode 100644 index 0000000..3da159c --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/SubscriberData.java @@ -0,0 +1,21 @@ +package ru.practicum.ewm.subscription.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.practicum.ewm.enums.FriendshipsStatus; + +import java.time.LocalDateTime; + +@FieldDefaults(level = AccessLevel.PRIVATE) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubscriberData { + Long userId; + String ownerName; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime subscribeTime; + FriendshipsStatus friendshipsStatus; +} diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/SubscriptionDto.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/SubscriptionDto.java new file mode 100644 index 0000000..def6fc8 --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/dto/SubscriptionDto.java @@ -0,0 +1,29 @@ +package ru.practicum.ewm.subscription.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.practicum.ewm.enums.FriendshipsStatus; +import ru.practicum.ewm.user.dto.UserDto; + +import java.time.LocalDateTime; + +@FieldDefaults(level = AccessLevel.PRIVATE) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubscriptionDto { + + Long id; + Long followerId; + UserDto owner; + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime subscribeTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime unsubscribeTime; + FriendshipsStatus friendshipsStatus; +} + diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/mapper/SubscriptionMapper.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/mapper/SubscriptionMapper.java new file mode 100644 index 0000000..3618ec8 --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/mapper/SubscriptionMapper.java @@ -0,0 +1,55 @@ +package ru.practicum.ewm.subscription.mapper; + +import ru.practicum.ewm.enums.FriendshipsStatus; +import ru.practicum.ewm.subscription.dto.NewRequestSubscription; +import ru.practicum.ewm.subscription.dto.SubscriberData; +import ru.practicum.ewm.subscription.dto.SubscriptionDto; +import ru.practicum.ewm.subscription.model.Subscription; +import ru.practicum.ewm.user.mapper.UserMapper; +import ru.practicum.ewm.user.model.User; + +import java.time.LocalDateTime; + +public class SubscriptionMapper { + + public static Subscription toNewSubscriptionFromRequest(User subscriber, NewRequestSubscription requestSubscription, User owner) { + return Subscription.builder() + .follower(subscriber) + .owner(owner) + .subscribeTime(LocalDateTime.now()) + .unsubscribeTime(null) + .friendshipsStatus(FriendshipsStatus.NO_FRIENDSHIP) + .build(); + } + + public static SubscriptionDto toSubscriptionDto(Subscription subscription) { + return SubscriptionDto.builder() + .id(subscription.getId()) + .followerId(subscription.getFollower().getId()) + .owner(UserMapper.toUserDto(subscription.getOwner())) + .subscribeTime(subscription.getSubscribeTime()) + .unsubscribeTime(subscription.getUnsubscribeTime()) + .friendshipsStatus(subscription.getFriendshipsStatus()) + .build(); + } + + public static SubscriptionDto toSubscriptionDtoWithoutUnsubscribeTime(Subscription subscription) { + return SubscriptionDto.builder() + .id(subscription.getId()) + .followerId(subscription.getFollower().getId()) + .owner(UserMapper.toUserDto(subscription.getOwner())) + .subscribeTime(subscription.getSubscribeTime()) + .friendshipsStatus(subscription.getFriendshipsStatus()) + .build(); + } + + public static SubscriberData toSubscriberData(Subscription subscription) { + return SubscriberData.builder() + .userId(subscription.getFollower().getId()) + .ownerName(subscription.getOwner().getName()) + .subscribeTime(subscription.getSubscribeTime()) + .friendshipsStatus(subscription.getFriendshipsStatus()) + .build(); + } + +} \ No newline at end of file diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/model/Subscription.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/model/Subscription.java new file mode 100644 index 0000000..78d3838 --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/model/Subscription.java @@ -0,0 +1,41 @@ +package ru.practicum.ewm.subscription.model; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.FieldDefaults; +import ru.practicum.ewm.enums.FriendshipsStatus; +import ru.practicum.ewm.user.model.User; + +import java.time.LocalDateTime; + +@FieldDefaults(level = AccessLevel.PRIVATE) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "subscriptions") +public class Subscription { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + @ManyToOne + @JoinColumn(name = "follower_id") + User follower; + + @ManyToOne + @JoinColumn(name = "owner_id") + User owner; + + @Column(name = "subscribe_time") + LocalDateTime subscribeTime; + + @Column(name = "unsubscribe_time") + LocalDateTime unsubscribeTime; + + @Enumerated(EnumType.STRING) + @Column(name = "friendships_status", nullable = false) + FriendshipsStatus friendshipsStatus; +} + diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/service/SubscriptionService.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/service/SubscriptionService.java new file mode 100644 index 0000000..ee3921c --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/service/SubscriptionService.java @@ -0,0 +1,20 @@ +package ru.practicum.ewm.subscription.service; + +import ru.practicum.ewm.event.dto.EventShortDto; +import ru.practicum.ewm.subscription.dto.NewRequestSubscription; +import ru.practicum.ewm.subscription.dto.SubscriberData; +import ru.practicum.ewm.subscription.dto.SubscriptionDto; + +import java.util.List; + +public interface SubscriptionService { + SubscriptionDto subscribe(Long userId, NewRequestSubscription requestSubscription); + + void unsubscribe(Long userId, Long ownerId); + + List getEventsFromSubscriptions(Long userId, int from, int size); + + Long getSubscriberCount(Long userId); + + List getAllSubscribers(Long userId, int from, int size); +} \ No newline at end of file diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/service/SubscriptionServiceImpl.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/service/SubscriptionServiceImpl.java new file mode 100644 index 0000000..b1c7f08 --- /dev/null +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/subscription/service/SubscriptionServiceImpl.java @@ -0,0 +1,172 @@ +package ru.practicum.ewm.subscription.service; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.practicum.ewm.enums.FriendshipsStatus; +import ru.practicum.ewm.event.EventRepository; +import ru.practicum.ewm.event.dto.EventShortDto; +import ru.practicum.ewm.event.mapper.EventMapper; +import ru.practicum.ewm.event.model.Event; +import ru.practicum.ewm.exception.ConflictException; +import ru.practicum.ewm.exception.NotFoundException; +import ru.practicum.ewm.subscription.SubscriptionRepository; +import ru.practicum.ewm.subscription.dto.NewRequestSubscription; +import ru.practicum.ewm.subscription.dto.SubscriberData; +import ru.practicum.ewm.subscription.dto.SubscriptionDto; +import ru.practicum.ewm.subscription.mapper.SubscriptionMapper; +import ru.practicum.ewm.subscription.model.Subscription; +import ru.practicum.ewm.user.UserRepository; +import ru.practicum.ewm.user.model.User; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class SubscriptionServiceImpl implements SubscriptionService { + UserRepository userRepository; + SubscriptionRepository subscriptionRepository; + EventRepository eventRepository; + + @Override + @Transactional + public SubscriptionDto subscribe(Long userId, NewRequestSubscription requestSubscription) { + User follower = findUser(userId); + User owner = findUser(requestSubscription.getOwnerId()); + + validateSubscription(follower, owner); + + Subscription subscription = createOrUpdateSubscription(follower, owner, requestSubscription); + log.info("Подписка успешно создана для пользователя {} на пользователя {}. Статус дружбы: {}", + userId, requestSubscription.getOwnerId(), subscription.getFriendshipsStatus()); + return SubscriptionMapper.toSubscriptionDtoWithoutUnsubscribeTime(subscription); + } + + @Override + @Transactional + public void unsubscribe(Long userId, Long ownerId) { + User follower = findUser(userId); + User owner = findUser(ownerId); + log.info("Обработка запроса на отмену подписки от пользователя {} к пользователю {}", userId, ownerId); + + Optional subscription = subscriptionRepository + .findByFollowerAndOwner(follower, owner); + if (subscription.isEmpty()) { + log.warn("У пользователя {} нет подписки на пользователя {}", userId, ownerId); + throw new ConflictException("У пользователя нет подписки на пользователя"); + } + Optional reverseSubscription = subscriptionRepository + .findByFollowerAndOwner(owner, follower); + if (reverseSubscription.isPresent() && + reverseSubscription.get().getFriendshipsStatus().equals(FriendshipsStatus.MUTUAL)) { + reverseSubscription.get().setFriendshipsStatus(FriendshipsStatus.ONE_SIDED); + reverseSubscription.get().setUnsubscribeTime(LocalDateTime.now()); + subscriptionRepository.save(reverseSubscription.get()); + } + subscriptionRepository.delete(subscription.get()); + log.info("Пользователь {} успешно отписался от {}. Статус дружбы обновлен: {}", + userId, ownerId, reverseSubscription.map(Subscription::getFriendshipsStatus).orElse(null)); + } + + @Override + @Transactional(readOnly = true) + public List getEventsFromSubscriptions(Long userId, int from, int size) { + User follower = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("Пользователь с ID " + userId + " не найден")); + + List subscriptions = subscriptionRepository.findByFollower(follower); + + List ownerIds = subscriptions.stream() + .map(subscription -> subscription.getOwner().getId()) + .collect(Collectors.toList()); + + PageRequest pageRequest = PageRequest.of(from / size, size, Sort.by("eventDate").descending()); + List events = eventRepository.findByInitiatorIdIn(ownerIds, pageRequest); + + return events.stream() + .map(EventMapper::toEventShortDto) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public Long getSubscriberCount(Long userId) { + log.info("Получение количества подписчиков для пользователя с ID: {}", userId); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("Пользователь с ID " + userId + " не найден")); + + long count = subscriptionRepository.countByOwnerAndFriendshipsStatusIn(user, List.of(FriendshipsStatus.ONE_SIDED, FriendshipsStatus.MUTUAL)); + log.info("У пользователя {} {} подписчиков.", userId, count); + return count; + } + + @Transactional(readOnly = true) + @Override + public List getAllSubscribers(Long userId, int from, int size) { + User owner = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("Пользователь с ID " + userId + " не найден")); + + Pageable pageable = PageRequest.of(from / size, size); + List subscriptions = subscriptionRepository.findByOwner(owner, pageable); + return subscriptions.stream() + .map(SubscriptionMapper::toSubscriberData) + .collect(Collectors.toList()); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException("Пользователь c ID " + userId + " не найден")); + } + + private void validateSubscription(User follower, User owner) { + if (!follower.isAllowSubscriptions()) { + log.warn("Пользователь {} не разрешает подписки", follower.getId()); + throw new ConflictException("Пользователь не разрешает подписки"); + } + + if (follower.getId().equals(owner.getId())) { + log.warn("Пользователь {} не может подписаться на самого себя", follower.getId()); + throw new ConflictException("Пользователь не может подписаться на самого себя"); + } + + Optional existingSubscription = subscriptionRepository.findByFollowerAndOwner(follower, owner); + if (existingSubscription.isPresent()) { + Subscription sub = existingSubscription.get(); + if (sub.getFriendshipsStatus() == FriendshipsStatus.ONE_SIDED || sub.getFriendshipsStatus() == FriendshipsStatus.MUTUAL) { + log.warn("У пользователя {} уже есть подписка на пользователя {}", follower.getId(), owner.getId()); + throw new ConflictException("У пользователя уже есть подписка на пользователя"); + } + } + } + + private Subscription createOrUpdateSubscription(User follower, User owner, NewRequestSubscription requestSubscription) { + Subscription subscription = SubscriptionMapper.toNewSubscriptionFromRequest(follower, requestSubscription, owner); + Optional existingReverseSubscription = subscriptionRepository.findByFollowerAndOwner(owner, follower); + + if (existingReverseSubscription.isPresent()) { + subscription.setFriendshipsStatus(FriendshipsStatus.MUTUAL); + subscription.setSubscribeTime(LocalDateTime.now()); + + existingReverseSubscription.get().setFriendshipsStatus(FriendshipsStatus.MUTUAL); + existingReverseSubscription.get().setSubscribeTime(LocalDateTime.now()); + subscriptionRepository.saveAll(List.of(subscription, existingReverseSubscription.get())); + } else { + subscription.setFriendshipsStatus(FriendshipsStatus.ONE_SIDED); + subscription.setSubscribeTime(LocalDateTime.now()); + subscriptionRepository.save(subscription); + } + return subscription; + } +} \ No newline at end of file diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/user/dto/UserDto.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/user/dto/UserDto.java index af667e4..21fc195 100644 --- a/ewm-main-svc/src/main/java/ru/practicum/ewm/user/dto/UserDto.java +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/user/dto/UserDto.java @@ -12,4 +12,5 @@ public class UserDto { Long id; String name; String email; + boolean allowSubscriptions; } diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/user/mapper/UserMapper.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/user/mapper/UserMapper.java index 11c7ebb..9a9103d 100644 --- a/ewm-main-svc/src/main/java/ru/practicum/ewm/user/mapper/UserMapper.java +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/user/mapper/UserMapper.java @@ -11,6 +11,7 @@ public static User toNewUserFromRequest(NewUserRequest request) { return User.builder() .name(request.getName()) .email(request.getEmail()) + .allowSubscriptions(true) .build(); } @@ -19,6 +20,7 @@ public static UserDto toUserDto(User user) { .id(user.getId()) .name(user.getName()) .email(user.getEmail()) + .allowSubscriptions(user.isAllowSubscriptions()) .build(); } diff --git a/ewm-main-svc/src/main/java/ru/practicum/ewm/user/model/User.java b/ewm-main-svc/src/main/java/ru/practicum/ewm/user/model/User.java index 12fe985..af082a7 100644 --- a/ewm-main-svc/src/main/java/ru/practicum/ewm/user/model/User.java +++ b/ewm-main-svc/src/main/java/ru/practicum/ewm/user/model/User.java @@ -21,4 +21,8 @@ public class User { @Column(unique = true, nullable = false, length = 254) String email; + + @Column(name = "allow_subscriptions", nullable = false) + @Builder.Default + boolean allowSubscriptions = true; } diff --git a/ewm-main-svc/src/main/resources/schema.sql b/ewm-main-svc/src/main/resources/schema.sql index 1e7f724..68d2bb2 100644 --- a/ewm-main-svc/src/main/resources/schema.sql +++ b/ewm-main-svc/src/main/resources/schema.sql @@ -9,7 +9,8 @@ DROP TABLE IF EXISTS requests CASCADE; CREATE TABLE IF NOT EXISTS users( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(250) NOT NULL, - email VARCHAR(254) NOT NULL UNIQUE + email VARCHAR(254) NOT NULL UNIQUE, + allow_subscriptions BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE IF NOT EXISTS categories( @@ -65,4 +66,15 @@ CREATE TABLE IF NOT EXISTS requests ( FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT unique_request UNIQUE (event_id, requester_id) +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + follower_id BIGINT NOT NULL, + owner_id BIGINT NOT NULL, + subscribe_time TIMESTAMP WITHOUT TIME ZONE NOT NULL, + unsubscribe_time TIMESTAMP WITHOUT TIME ZONE, + friendships_status VARCHAR(50) NOT NULL, + FOREIGN KEY (follower_id) REFERENCES users(id), + FOREIGN KEY (owner_id) REFERENCES users(id) ); \ No newline at end of file diff --git a/postman/feature.json b/postman/feature.json new file mode 100644 index 0000000..2a0123b --- /dev/null +++ b/postman/feature.json @@ -0,0 +1,751 @@ +{ + "info": { + "_postman_id": "73f49395-7882-406a-8806-f6f49e2b0fa9", + "name": "Feature", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "44101787", + "_collection_link": "https://www.postman.com/red-capsule-798716/my-workspace/collection/kgnsbxi/feature?action=share&source=collection_link&creator=44101787" + }, + "item": [ + { + "name": "Создание подписки на пользователя", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 201\", () => {\r", + " pm.response.to.have.status(201);\r", + "});\r", + "\r", + "pm.test(\"Content-Type is application/json\", () => {\r", + " pm.response.to.have.header(\"Content-Type\", \"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Ответ с id\", () => {\r", + " const responseJson = pm.response.json();\r", + " pm.expect(responseJson).to.have.property(\"id\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "\r", + "const main = async () => {\r", + " const api = new API(pm); \r", + " const rnd = new RandomUtils(); \r", + " let event;\r", + " try {\r", + " \r", + " const user = await api.addUser(rnd.getUser());\r", + " pm.collectionVariables.set(\"uid\", user.id);\r", + "\r", + " const category = await api.addCategory(rnd.getCategory());\r", + "\r", + " \r", + " event = rnd.getEvent(category.id);\r", + " event.ownerId = 2;\r", + "\r", + " } catch (err) {\r", + " console.error(\"Error during test data setup:\", err);\r", + " }\r", + "\r", + " pm.request.body.update({\r", + " mode: \"raw\",\r", + " raw: JSON.stringify(event),\r", + " options: { raw: { language: \"json\" } },\r", + " });\r", + "\r", + " console.log(\"Request Body: \", pm.request.body.raw)\r", + "\r", + "};\r", + "\r", + "setTimeout(async () => {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(\"Main function error:\", e);\r", + " }\r", + "}, 100);\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/subscriptions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "subscriptions" + ] + } + }, + "response": [] + }, + { + "name": "Получение всех событий на подписанного пользователя", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "\r", + "const main = async () => {\r", + " const api = new API(pm);\r", + " const rnd = new RandomUtils();\r", + "\r", + " const ownerId = 2;\r", + " const requestSubscription = { ownerId: ownerId };\r", + "\r", + " pm.request.body.update({\r", + " mode: 'raw',\r", + " raw: JSON.stringify(requestSubscription),\r", + " options: { raw: { language: 'json' } }\r", + " });\r", + "\r", + "\r", + " try {\r", + " const subscriptionResponse = await pm.sendRequest({\r", + " url: `{{baseUrl}}/users/${userId}/subscriptions`, \r", + " method: 'GET'\r", + " });\r", + "\r", + " const subscriptions = JSON.parse(subscriptionResponse.body); \r", + " if (!Array.isArray(subscriptions)) {\r", + " console.error(\"Expected an array of subscriptions, but got:\", subscriptions);\r", + " return;\r", + " }\r", + "\r", + " } catch (error) {\r", + " console.error(\"Error fetching subscriptions:\", error);\r", + " }\r", + "};\r", + "\r", + "setTimeout(async () => {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " }\r", + "}, 100);\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "\r", + "pm.test(\"Статус код должен быть 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Ответ должен быть в формате is JSON array\", () => {\r", + " pm.response.to.be.json;\r", + " const response = pm.response.json();\r", + " pm.expect(Array.isArray(response)).to.be.true;\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/subscriptions/events?from=0&size=10", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "subscriptions", + "events" + ], + "query": [ + { + "key": "from", + "value": "0" + }, + { + "key": "size", + "value": "10" + } + ] + } + }, + "response": [] + }, + { + "name": "Получение количества подписчиков пользователя", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "\r", + "const main = async () => {\r", + " \r", + " const api = new API(pm); \r", + " const rnd = new RandomUtils(); \r", + " let userId = pm.environment.get(\"userId\");\r", + "\r", + " pm.environment.set(\"userId\", \"2\");\r", + "};\r", + "\r", + "setTimeout(async () => {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " }\r", + "}, 100);\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"Количество подписчиков у вас \", () => {\r", + " const response = pm.response.json();\r", + " pm.expect(typeof response).to.equal('number');\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/subscriptions/subscribers/count", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "subscriptions", + "subscribers", + "count" + ] + } + }, + "response": [] + }, + { + "name": "Получить данные всех подписчиков", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "\r", + "const main = async () => {\r", + " \r", + " const api = new API(pm); \r", + " const rnd = new RandomUtils(); \r", + " let userId = pm.environment.get(\"userId\");\r", + "\r", + " pm.environment.set(\"userId\", \"2\");\r", + "};\r", + "\r", + "setTimeout(async () => {\r", + " try {\r", + " await main();\r", + " } catch (e) {\r", + " console.error(e);\r", + " }\r", + "}, 100);\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Статус код должен быть 200\", () => {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"ответ должен быть в формате JSON array\", () => {\r", + " pm.response.to.be.json;\r", + " const response = pm.response.json();\r", + " pm.expect(Array.isArray(response)).to.be.true;\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/subscriptions/subscribers", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "subscriptions", + "subscribers" + ] + } + }, + "response": [] + }, + { + "name": "Отписка от пользователя", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const userId = pm.collectionVariables.get(\"userId\");\r", + "pm.environment.set(\"userId\", userId);\r", + "\r", + "const ownerId = pm.collectionVariables.get(\"ownerId\");\r", + "pm.environment.set(\"ownerId\", ownerId);\r", + "\r", + "let requestOwnerId = pm.environment.get(\"ownerId\");\r", + "if (!requestOwnerId) {\r", + " requestOwnerId = \"2\";\r", + " pm.environment.set(\"ownerId\", requestOwnerId);\r", + "}\r", + "console.log(`Удаление подписки пользователя ${userId} от пользователя ${ownerId}`);\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Успешный статус 204 No Content\", function () {\r", + " pm.response.to.have.status(204);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/users/{{userId}}/subscriptions/{{ownerId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "{{userId}}", + "subscriptions", + "{{ownerId}}" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "API = class {\r", + " constructor(postman, verbose = false, baseUrl = \"http://localhost:8080\") {\r", + " this.baseUrl = baseUrl;\r", + " this.pm = postman;\r", + " this._verbose = verbose;\r", + " }\r", + "\r", + " async addUser(user, verbose=null) {\r", + " return this.post(\"/admin/users\", user, \"Ошибка при добавлении нового пользователя: \", verbose);\r", + " }\r", + "\r", + " async addCategory(category, verbose=null) {\r", + " return this.post(\"/admin/categories\", category, \"Ошибка при добавлении новой категории: \", verbose);\r", + " }\r", + "\r", + " async addEvent(userId, event, verbose=null) {\r", + " return this.post(\"/users/\" + userId + \"/events\", event, \"Ошибка при добавлении нового события: \", verbose);\r", + " }\r", + "\r", + " async addCompilation(compilation, verbose=null) {\r", + " return this.post(\"/admin/compilations\", compilation, \"Ошибка при добавлении новой подборки: \", verbose);\r", + " }\r", + "\r", + " async publishParticipationRequest(eventId, userId, verbose=null) {\r", + " return this.post('/users/' + userId + '/requests?eventId=' + eventId, null, \"Ошибка при добавлении нового запроса на участие в событии\", verbose);\r", + " }\r", + "\r", + " async publishEvent(eventId, verbose=null) {\r", + " return this.patch('/admin/events/' + eventId, {stateAction: \"PUBLISH_EVENT\"}, \"Ошибка при публикации события\", verbose);\r", + " }\r", + " \r", + " async rejectEvent(eventId, verbose=null) {\r", + " return this.patch('/admin/events/' + eventId, {stateAction: \"REJECT_EVENT\"}, \"Ошибка при отмене события\", verbose);\r", + " }\r", + "\r", + " async acceptParticipationRequest(eventId, userId, reqId, verbose=null) {\r", + " return this.patch('/users/' + userId + '/events/' + eventId + '/requests', {requestIds:[reqId], status: \"CONFIRMED\"}, \"Ошибка при принятии заявки на участие в событии\", verbose);\r", + " }\r", + "\r", + " async findCategory(catId, verbose=null) {\r", + " return this.get('/categories/' + catId, null, \"Ошибка при поиске категории по id\", verbose);\r", + " }\r", + "\r", + " async findCompilation(compId, verbose=null) {\r", + " return this.get('/compilations/' + compId, null, \"Ошибка при поиске подборки по id\", verbose);\r", + " }\r", + "\r", + " async findEvent(eventId, verbose=null) {\r", + " return this.get('/events/' + eventId, null, \"Ошибка при поиске события по id\", verbose);\r", + " }\r", + "\r", + " async findUser(userId, verbose=null) {\r", + " return this.get('/admin/users?ids=' + userId, null, \"Ошибка при поиске пользователя по id\", verbose);\r", + " }\r", + "\r", + " async post(path, body, errorText = \"Ошибка при выполнении post-запроса: \", verbose=null) {\r", + " return this.sendRequest(\"POST\", path, body, errorText, verbose);\r", + " }\r", + "\r", + " async patch(path, body = null, errorText = \"Ошибка при выполнении patch-запроса: \", verbose=null) {\r", + " return this.sendRequest(\"PATCH\", path, body, errorText, verbose);\r", + " }\r", + "\r", + " async get(path, body = null, errorText = \"Ошибка при выполнении get-запроса: \", verbose=null) {\r", + " return this.sendRequest(\"GET\", path, body, errorText, verbose);\r", + " }\r", + " async addComment(userId, eventId, comment, verbose=null) {\r", + " return this.post(`/users/${userId}/comments?eventId=${eventId}`, comment, \"Ошибка при добавлении комментария: \", verbose);\r", + " }\r", + " \r", + " async sendRequest(method, path, body=null, errorText = \"Ошибка при выполнении запроса: \", verbose=null) {\r", + " return new Promise((resolve, reject) => {\r", + " verbose = verbose == null ? this._verbose : verbose;\r", + " const request = {\r", + " url: this.baseUrl + path,\r", + " method: method,\r", + " body: body == null ? \"\" : JSON.stringify(body),\r", + " header: { \"Content-Type\": \"application/json\" },\r", + " };\r", + " if(verbose) {\r", + " console.log(\"Отправляю запрос: \", request);\r", + " }\r", + "\r", + " try {\r", + " this.pm.sendRequest(request, (error, response) => {\r", + " if(error || (response.code >= 400 && response.code <= 599)) {\r", + " let err = error ? error : JSON.stringify(response.json());\r", + " console.error(\"При выполнении запроса к серверу возникла ошика.\\n\", err,\r", + " \"\\nДля отладки проблемы повторите такой же запрос к вашей программе \" + \r", + " \"на локальном компьютере. Данные запроса:\\n\", JSON.stringify(request));\r", + "\r", + " reject(new Error(errorText + err));\r", + " }\r", + " if(verbose) {\r", + " console.log(\"Результат обработки запроса: код состояния - \", response.code, \", тело: \", response.json());\r", + " }\r", + " if (response.stream.length === 0){\r", + " reject(new Error('Отправлено пустое тело ответа'))\r", + " }else{\r", + " resolve(response.json());\r", + " }\r", + " });\r", + " \r", + " } catch(err) {\r", + " if(verbose) {\r", + " console.error(errorText, err);\r", + " }\r", + " return Promise.reject(err);\r", + " }\r", + " });\r", + " }\r", + "};\r", + "\r", + "RandomUtils = class {\r", + " constructor() {}\r", + "\r", + " getUser() {\r", + " return {\r", + " name: pm.variables.replaceIn('{{$randomFullName}}'),\r", + " email: pm.variables.replaceIn('{{$randomEmail}}')\r", + " };\r", + " }\r", + "\r", + " getComment() {\r", + " return {\r", + " text: pm.variables.replaceIn('{{$randomLoremSentence}}') + \" \" + this.getWord(5)\r", + " };\r", + " }\r", + "\r", + " getCategory() {\r", + " return {\r", + " name: pm.variables.replaceIn('{{$randomWord}}') + Math.floor(Math.random() * 10000 * Math.random()).toString()\r", + " };\r", + " }\r", + "\r", + " getEvent(categoryId) {\r", + " return {\r", + " annotation: pm.variables.replaceIn('{{$randomLoremParagraph}}'),\r", + " category: categoryId,\r", + " description: pm.variables.replaceIn('{{$randomLoremParagraphs}}'),\r", + " eventDate: this.getFutureDateTime(),\r", + " location: {\r", + " lat: parseFloat(pm.variables.replaceIn('{{$randomLatitude}}')),\r", + " lon: parseFloat(pm.variables.replaceIn('{{$randomLongitude}}')),\r", + " },\r", + " paid: pm.variables.replaceIn('{{$randomBoolean}}'),\r", + " participantLimit: pm.variables.replaceIn('{{$randomInt}}'),\r", + " requestModeration: pm.variables.replaceIn('{{$randomBoolean}}'),\r", + " title: pm.variables.replaceIn('{{$randomLoremSentence}}'),\r", + " }\r", + " }\r", + "\r", + " getCompilation(...eventIds) {\r", + " return {\r", + " title: pm.variables.replaceIn('{{$randomLoremSentence}}').slice(0, 50),\r", + " pinned: pm.variables.replaceIn('{{$randomBoolean}}'),\r", + " events: eventIds\r", + " };\r", + " }\r", + "\r", + "\r", + " getFutureDateTime(hourShift = 5, minuteShift=0, yearShift=0) {\r", + " let moment = require('moment');\r", + "\r", + " let m = moment();\r", + " m.add(hourShift, 'hour');\r", + " m.add(minuteShift, 'minute');\r", + " m.add(yearShift, 'year');\r", + "\r", + " return m.format('YYYY-MM-DD HH:mm:ss');\r", + " }\r", + "\r", + " getWord(length = 1) {\r", + " let result = '';\r", + " const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\r", + " const charactersLength = characters.length;\r", + " let counter = 0;\r", + " while (counter < length) {\r", + " result += characters.charAt(Math.floor(Math.random() * charactersLength));\r", + " counter += 1;\r", + " }\r", + " return result;\r", + " }\r", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080", + "type": "string" + }, + { + "key": "name", + "value": "", + "type": "string" + }, + { + "key": "usersCount", + "value": "1", + "type": "string" + }, + { + "key": "catid", + "value": "1", + "type": "string" + }, + { + "key": "request_body", + "value": "", + "type": "string" + }, + { + "key": "mail", + "value": "", + "type": "string" + }, + { + "key": "response", + "value": "", + "type": "string" + }, + { + "key": "uid", + "value": "1", + "type": "string" + }, + { + "key": "catname", + "value": "", + "type": "string" + }, + { + "key": "eid", + "value": "1", + "type": "string" + }, + { + "key": "compid", + "value": "1", + "type": "string" + }, + { + "key": "toCheck", + "value": "", + "type": "string" + }, + { + "key": "newDataToSet", + "value": "", + "type": "string" + }, + { + "key": "uid1", + "value": "1", + "type": "string" + }, + { + "key": "reqid", + "value": "1", + "type": "string" + }, + { + "key": "catId", + "value": "", + "type": "string" + }, + { + "key": "confirmedRequests", + "value": "", + "type": "string" + }, + { + "key": "responseArr", + "value": "", + "type": "string" + }, + { + "key": "source1", + "value": "", + "type": "string" + }, + { + "key": "source2", + "value": "", + "type": "string" + }, + { + "key": "fromId", + "value": "0", + "type": "string" + }, + { + "key": "source", + "value": "", + "type": "string" + }, + { + "key": "userId", + "value": "1" + }, + { + "key": "followerId", + "value": "", + "disabled": true + }, + { + "value": "", + "disabled": true + } + ] +} \ No newline at end of file