diff --git a/RUN_TESTS.md b/RUN_TESTS.md index 3a3607d..c727e62 100644 --- a/RUN_TESTS.md +++ b/RUN_TESTS.md @@ -34,5 +34,10 @@ docker compose up --build Тестовые данные для локальной БД загружаются отдельно: ```bash +chmod +x scripts/seed-test-data.sh +chmod +x scripts/upload-test-photos.sh ./scripts/seed-test-data.sh +./scripts/upload-test-photos.sh ``` + +Перед сидированием лучше пересобрать контейнер. Для загрузки фотографий необходимо установить `aws cli`, если его еще нет. diff --git a/build.gradle b/build.gradle index 3919db5..dfa65a1 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' implementation platform("software.amazon.awssdk:bom:2.25.0") implementation "software.amazon.awssdk:s3" diff --git a/scripts/test-photos/avatar.jpg b/scripts/test-photos/avatar.png similarity index 100% rename from scripts/test-photos/avatar.jpg rename to scripts/test-photos/avatar.png diff --git a/scripts/test-photos/curb.png b/scripts/test-photos/curb.png new file mode 100644 index 0000000..86b62b7 Binary files /dev/null and b/scripts/test-photos/curb.png differ diff --git a/scripts/test-photos/gravel.jpg b/scripts/test-photos/gravel.png similarity index 100% rename from scripts/test-photos/gravel.jpg rename to scripts/test-photos/gravel.png diff --git a/scripts/test-photos/potholes.jpg b/scripts/test-photos/potholes.png similarity index 100% rename from scripts/test-photos/potholes.jpg rename to scripts/test-photos/potholes.png diff --git a/scripts/test-photos/stairs.jpg b/scripts/test-photos/stairs.png similarity index 100% rename from scripts/test-photos/stairs.jpg rename to scripts/test-photos/stairs.png diff --git a/src/main/java/goodroad/model/Role.java b/src/main/java/goodroad/model/Role.java index 8d032bc..108cb55 100644 --- a/src/main/java/goodroad/model/Role.java +++ b/src/main/java/goodroad/model/Role.java @@ -2,6 +2,7 @@ public enum Role { USER, + VOLUNTEER, MODERATOR, MODERATOR_ADMIN } \ No newline at end of file diff --git a/src/main/java/goodroad/security/SecurityConfig.java b/src/main/java/goodroad/security/SecurityConfig.java index 8351047..2098ccb 100644 --- a/src/main/java/goodroad/security/SecurityConfig.java +++ b/src/main/java/goodroad/security/SecurityConfig.java @@ -35,6 +35,7 @@ public SecurityFilterChain chain(HttpSecurity http) throws Exception { //.requestMatchers("/users/moderators/{id}").hasAnyRole("MODERATOR", "MODERATOR_ADMIN") // TODO: возможно пересмотреть права пользователей на этот ендпоинт .requestMatchers("/users/moderators/**").hasRole("MODERATOR_ADMIN") .requestMatchers("/reviews/moderation/**").hasAnyRole("MODERATOR", "MODERATOR_ADMIN") + .requestMatchers("/volunteer/moderation/**").hasAnyRole("MODERATOR", "MODERATOR_ADMIN") .anyRequest().authenticated() ) .build(); diff --git a/src/main/java/goodroad/storage/StorageService.java b/src/main/java/goodroad/storage/StorageService.java index f2c7262..e7b630b 100644 --- a/src/main/java/goodroad/storage/StorageService.java +++ b/src/main/java/goodroad/storage/StorageService.java @@ -76,6 +76,37 @@ public String uploadReviewPhoto(MultipartFile file, String userId) { } } + public String uploadVolunteerCertificate(MultipartFile file, String userId) { + + try { + + String ext = getExt(file.getOriginalFilename()); + + String key = "volunteer-certificates/" + + userId + + "/" + + UUID.randomUUID() + + ext; + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .build(), + RequestBody.fromBytes(file.getBytes()) + ); + + return "https://storage.yandexcloud.net/" + + bucket + + "/" + + key; + + } catch (Exception e) { + throw new RuntimeException("Upload failed", e); + } + } + private String getExt(String name) { if (name == null) return ""; int i = name.lastIndexOf("."); diff --git a/src/main/java/goodroad/volunteer/VolunteerController.java b/src/main/java/goodroad/volunteer/VolunteerController.java new file mode 100644 index 0000000..91becf6 --- /dev/null +++ b/src/main/java/goodroad/volunteer/VolunteerController.java @@ -0,0 +1,112 @@ +package goodroad.volunteer; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/volunteer") +@RequiredArgsConstructor +public class VolunteerController { + private final VolunteerService service; + + @GetMapping("/menu") + public VolunteerService.VolunteerMenuResp getMenu(Authentication authentication) { + return service.getMenu(authentication.getName()); + } + + @PostMapping("/applications") + public VolunteerService.VolunteerApplicationResp createApplication( + Authentication authentication, + @RequestBody VolunteerService.CreateVolunteerApplicationReq req + ) { + return service.createApplication(authentication.getName(), req); + } + + @PostMapping(value = "/applications/photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public VolunteerService.PhotoUploadResp uploadCertificate( + Authentication authentication, + @RequestParam("file") MultipartFile file + ) { + return service.uploadCertificate(authentication.getName(), file); + } + + @GetMapping("/requests/own") + public List listOwnRequests(Authentication authentication) { + return service.listOwnRequests(authentication.getName()); + } + + @PostMapping("/requests") + public VolunteerService.HelpRequestResp createHelpRequest( + Authentication authentication, + @RequestBody VolunteerService.HelpRequestReq req + ) { + return service.createHelpRequest(authentication.getName(), req); + } + + @GetMapping("/requests/available") + public List listAvailableRequests( + Authentication authentication, + @RequestParam(required = false) Double latitude, + @RequestParam(required = false) Double longitude + ) { + return service.listAvailableRequests(authentication.getName(), latitude, longitude); + } + + @GetMapping("/requests/my-wards") + public List listMyWards(Authentication authentication) { + return service.listMyWards(authentication.getName()); + } + + @GetMapping("/requests/{id}") + public VolunteerService.HelpRequestResp getHelpRequest(Authentication authentication, @PathVariable String id) { + return service.getHelpRequest(authentication.getName(), id); + } + + @PostMapping("/requests/{id}/accept") + public VolunteerService.HelpRequestResp acceptRequest(Authentication authentication, @PathVariable String id) { + return service.acceptRequest(authentication.getName(), id); + } + + @PostMapping("/requests/{id}/withdraw") + public VolunteerService.HelpRequestResp withdrawResponse(Authentication authentication, @PathVariable String id) { + return service.withdrawResponse(authentication.getName(), id); + } + + @PostMapping("/requests/{id}/cancel") + public VolunteerService.HelpRequestResp cancelOwnRequest(Authentication authentication, @PathVariable String id) { + return service.cancelOwnRequest(authentication.getName(), id); + } + + @DeleteMapping("/requests/{id}") + public void deleteOwnRequest(Authentication authentication, @PathVariable String id) { + service.deleteOwnRequest(authentication.getName(), id); + } + + @PostMapping("/requests/{id}/route") + public VolunteerService.HelpRequestResp setWalkRoute( + Authentication authentication, + @PathVariable String id, + @RequestBody VolunteerService.WalkRouteReq req + ) { + return service.setWalkRoute(authentication.getName(), id, req); + } + + @PostMapping("/requests/{id}/start") + public VolunteerService.HelpRequestResp startWalk( + Authentication authentication, + @PathVariable String id, + @RequestBody(required = false) VolunteerService.WalkRouteReq req + ) { + return service.startWalk(authentication.getName(), id, req); + } + + @PostMapping("/requests/{id}/finish") + public VolunteerService.HelpRequestResp finishWalk(Authentication authentication, @PathVariable String id) { + return service.finishWalk(authentication.getName(), id); + } +} diff --git a/src/main/java/goodroad/volunteer/VolunteerModerationController.java b/src/main/java/goodroad/volunteer/VolunteerModerationController.java new file mode 100644 index 0000000..53b0778 --- /dev/null +++ b/src/main/java/goodroad/volunteer/VolunteerModerationController.java @@ -0,0 +1,33 @@ +package goodroad.volunteer; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/volunteer/moderation") +@RequiredArgsConstructor +public class VolunteerModerationController { + private final VolunteerService service; + + @GetMapping("/applications/pending") + public List listPendingApplications() { + return service.listPendingApplications(); + } + + @PostMapping("/applications/{id}/approve") + public VolunteerService.VolunteerApplicationResp approveApplication(Authentication authentication, @PathVariable String id) { + return service.approveApplication(authentication.getName(), id); + } + + @PostMapping("/applications/{id}/reject") + public VolunteerService.VolunteerApplicationResp rejectApplication( + Authentication authentication, + @PathVariable String id, + @RequestBody VolunteerService.RejectApplicationReq req + ) { + return service.rejectApplication(authentication.getName(), id, req); + } +} diff --git a/src/main/java/goodroad/volunteer/VolunteerService.java b/src/main/java/goodroad/volunteer/VolunteerService.java new file mode 100644 index 0000000..f2d656e --- /dev/null +++ b/src/main/java/goodroad/volunteer/VolunteerService.java @@ -0,0 +1,594 @@ +package goodroad.volunteer; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import goodroad.api.ApiErrors.ApiException; +import goodroad.model.Role; +import goodroad.security.Crypto; +import goodroad.storage.StorageService; +import goodroad.users.repository.UserEntity; +import goodroad.users.repository.UserRepo; +import goodroad.validation.InputRules; +import goodroad.volunteer.repository.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; + +@Service +public class VolunteerService { + private static final int WALK_REWARD = 100; + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final UserRepo users; + private final VolunteerApplicationRepo applications; + private final VolunteerApplicationPhotoRepo applicationPhotos; + private final HelpRequestRepo requests; + private final StorageService storageService; + + public VolunteerService( + UserRepo users, + VolunteerApplicationRepo applications, + VolunteerApplicationPhotoRepo applicationPhotos, + HelpRequestRepo requests, + StorageService storageService + ) { + this.users = users; + this.applications = applications; + this.applicationPhotos = applicationPhotos; + this.requests = requests; + this.storageService = storageService; + } + + public record VolunteerMenuResp(boolean volunteer, String applicationStatus, String rejectReason) {} + public record CreateVolunteerApplicationReq(String dobroUrl, String phone, String socialNickname, List certificatePhotoUrls) {} + public record PhotoUploadResp(String photoUrl) {} + public record VolunteerApplicationResp(String id, String applicantId, String applicantName, String dobroUrl, String phone, String socialNickname, List certificatePhotoUrls, String status, String moderatorComment, Instant createdAt, Instant moderatedAt) {} + public record RejectApplicationReq(String reason) {} + public record HelpRequestReq(String fromAddress, String toAddress, String date, String time, String phone, String socialNickname, String comment) {} + public record HelpRequestResp(String id, String requesterId, String volunteerId, String fromAddress, String toAddress, String date, String time, String phone, String socialNickname, String comment, String status, boolean contactsVisible, boolean canStart, boolean started, boolean completed, Instant createdAt) {} + public record RoutePointReq(Double latitude, Double longitude) {} + public record WalkRouteReq( + @JsonProperty("points") @JsonAlias("encodedPoints") String encodedPoints, + @JsonProperty("routePoints") List routePoints + ) {} + + @Transactional(readOnly = true) + public VolunteerMenuResp getMenu(String phoneFromAuth) { + UserEntity user = findCurrent(phoneFromAuth); + VolunteerApplicationEntity last = applications.findFirstByApplicantIdOrderByCreatedAtDesc(user.getId()).orElse(null); + return new VolunteerMenuResp( + isVolunteer(user), + last == null ? null : last.getStatus(), + last == null ? null : last.getModeratorComment() + ); + } + + @Transactional + public VolunteerApplicationResp createApplication(String phoneFromAuth, CreateVolunteerApplicationReq req) { + if (req == null) { + throw bad("VOLUNTEER_APPLICATION_EMPTY", "Application is empty"); + } + UserEntity user = findCurrent(phoneFromAuth); + if (isVolunteer(user)) { + throw new ApiException(HttpStatus.CONFLICT, "ALREADY_VOLUNTEER", "User is already volunteer"); + } + applications.findFirstByApplicantIdOrderByCreatedAtDesc(user.getId()) + .filter(app -> "PENDING".equals(app.getStatus())) + .ifPresent(app -> { throw new ApiException(HttpStatus.CONFLICT, "APPLICATION_ALREADY_PENDING", "Application is already pending"); }); + + VolunteerApplicationEntity app = new VolunteerApplicationEntity(); + app.setApplicant(user); + app.setDobroUrl(requireUrl(req.dobroUrl(), "DOBRO_URL_INVALID", "Dobro.ru link is invalid")); + app.setPhone(Crypto.normPhone(req.phone())); + if (app.getPhone().isEmpty()) { + throw bad("PHONE_INVALID", "Phone is invalid"); + } + app.setSocialNickname(InputRules.trimToNull(req.socialNickname())); + applications.save(app); + + if (req.certificatePhotoUrls() != null) { + for (String rawUrl : req.certificatePhotoUrls()) { + String url = requireUrl(rawUrl, "CERTIFICATE_URL_INVALID", "Certificate URL is invalid"); + VolunteerApplicationPhotoEntity photo = new VolunteerApplicationPhotoEntity(); + photo.setApplication(app); + photo.setUrl(url); + app.getPhotos().add(photo); + } + } + return toApplicationResp(app); + } + + @Transactional + public PhotoUploadResp uploadCertificate(String phoneFromAuth, MultipartFile file) { + UserEntity user = findCurrent(phoneFromAuth); + String url = storageService.uploadVolunteerCertificate(file, user.getId().toString()); + return new PhotoUploadResp(url); + } + + @Transactional(readOnly = true) + public List listPendingApplications() { + return applications.findByStatusOrderByCreatedAtAsc("PENDING").stream().map(this::toApplicationResp).toList(); + } + + @Transactional + public VolunteerApplicationResp approveApplication(String moderatorPhone, String id) { + UserEntity moderator = requireModerator(moderatorPhone); + VolunteerApplicationEntity app = findApplication(id); + if (!"PENDING".equals(app.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "APPLICATION_ALREADY_PROCESSED", "Application already processed"); + } + app.setStatus("APPROVED"); + app.setModerator(moderator); + app.setModeratedAt(Instant.now()); + app.getApplicant().setRole(Role.VOLUNTEER.name()); + users.save(app.getApplicant()); + applications.save(app); + return toApplicationResp(app); + } + + @Transactional + public VolunteerApplicationResp rejectApplication(String moderatorPhone, String id, RejectApplicationReq req) { + UserEntity moderator = requireModerator(moderatorPhone); + VolunteerApplicationEntity app = findApplication(id); + String reason = InputRules.trimToNull(req == null ? null : req.reason()); + if (reason == null) { + throw bad("REJECT_REASON_EMPTY", "Reject reason is empty"); + } + if (!"PENDING".equals(app.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "APPLICATION_ALREADY_PROCESSED", "Application already processed"); + } + app.setStatus("REJECTED"); + app.setModeratorComment(reason); + app.setModerator(moderator); + app.setModeratedAt(Instant.now()); + return toApplicationResp(applications.save(app)); + } + + @Transactional + public HelpRequestResp createHelpRequest(String phoneFromAuth, HelpRequestReq req) { + if (req == null) { + throw bad("HELP_REQUEST_EMPTY", "Help request is empty"); + } + UserEntity requester = findCurrent(phoneFromAuth); + HelpRequestEntity request = new HelpRequestEntity(); + request.setRequester(requester); + fillRequest(request, req); + return toHelpResp(requests.save(request), requester, false); + } + + @Transactional(readOnly = true) + public List listOwnRequests(String phoneFromAuth) { + UserEntity user = findCurrent(phoneFromAuth); + return requests.findByRequesterIdOrderByDateDescTimeDescCreatedAtDesc(user.getId()).stream() + .map(request -> toHelpResp(request, user, true)) + .toList(); + } + + @Transactional(readOnly = true) + public List listMyWards(String phoneFromAuth) { + UserEntity volunteer = requireVolunteer(phoneFromAuth); + return requests.findByVolunteerIdOrderByDateDescTimeDescCreatedAtDesc(volunteer.getId()).stream() + .map(request -> toHelpResp(request, volunteer, true)) + .toList(); + } + + @Transactional(readOnly = true) + public List listAvailableRequests(String phoneFromAuth, Double latitude, Double longitude) { + UserEntity volunteer = requireVolunteer(phoneFromAuth); + return requests.findByStatusOrderByDateAscTimeAscCreatedAtAsc("OPEN").stream() + .filter(request -> !request.getRequester().getId().equals(volunteer.getId())) + .sorted(Comparator.comparing(HelpRequestEntity::getDate).thenComparing(HelpRequestEntity::getTime)) + .map(request -> toHelpResp(request, volunteer, false)) + .toList(); + } + + @Transactional(readOnly = true) + public HelpRequestResp getHelpRequest(String phoneFromAuth, String id) { + UserEntity user = findCurrent(phoneFromAuth); + return toHelpResp(findRequest(id), user, true); + } + + @Transactional + public HelpRequestResp cancelOwnRequest(String phoneFromAuth, String id) { + UserEntity user = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireRequester(request, user); + if ("COMPLETED".equals(request.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_COMPLETED", "Completed request cannot be cancelled"); + } + request.setStatus("CANCELLED"); + request.setCancelledAt(Instant.now()); + return toHelpResp(requests.save(request), user, true); + } + + @Transactional + public void deleteOwnRequest(String phoneFromAuth, String id) { + UserEntity user = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireRequester(request, user); + if ("COMPLETED".equals(request.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_COMPLETED", "Completed request cannot be deleted"); + } + if (request.getVolunteer() == null && "OPEN".equals(request.getStatus())) { + requests.delete(request); + return; + } + request.setStatus("CANCELLED"); + request.setCancelledAt(Instant.now()); + requests.save(request); + } + + @Transactional + public HelpRequestResp acceptRequest(String phoneFromAuth, String id) { + UserEntity volunteer = requireVolunteer(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + if (!"OPEN".equals(request.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_OPEN", "Help request is not open"); + } + if (request.getRequester().getId().equals(volunteer.getId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "OWN_REQUEST_ACCEPT", "Volunteer cannot accept own request"); + } + request.setVolunteer(volunteer); + request.setStatus("ACCEPTED"); + request.setAcceptedAt(Instant.now()); + return toHelpResp(requests.save(request), volunteer, true); + } + + @Transactional + public HelpRequestResp withdrawResponse(String phoneFromAuth, String id) { + UserEntity volunteer = requireVolunteer(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireVolunteerOfRequest(request, volunteer); + if (!"ACCEPTED".equals(request.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_CANNOT_WITHDRAW", "Response cannot be withdrawn"); + } + request.setVolunteer(null); + request.setAcceptedAt(null); + request.setStatus("OPEN"); + return toHelpResp(requests.save(request), volunteer, false); + } + + @Transactional + public HelpRequestResp startWalk(String phoneFromAuth, String id, WalkRouteReq routeReq) { + UserEntity user = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireParticipant(request, user); + if (!"ACCEPTED".equals(request.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_ACCEPTED", "Walk can start only for accepted request"); + } + if (routeReq != null) { + saveWalkRoute(request, routeReq); + } + Instant now = Instant.now(); + if (request.getRequester().getId().equals(user.getId())) { + if (request.getRequesterStartedAt() == null) { + requireNoActiveRequesterWalk(user, request); + request.setRequesterStartedAt(now); + } + } else { + if (request.getVolunteerStartedAt() == null) { + requireNoActiveVolunteerWalk(user, request); + request.setVolunteerStartedAt(now); + } + } + if (request.getRequesterStartedAt() != null && request.getVolunteerStartedAt() != null && request.getStartedAt() == null) { + request.setStartedAt(now); + } + return toHelpResp(requests.save(request), user, true); + } + + @Transactional + public HelpRequestResp setWalkRoute(String phoneFromAuth, String id, WalkRouteReq req) { + UserEntity user = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireParticipant(request, user); + if (!"ACCEPTED".equals(request.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_ACCEPTED", "Route can be saved only for accepted request"); + } + saveWalkRoute(request, req); + return toHelpResp(requests.save(request), user, true); + } + + @Transactional + public HelpRequestResp finishWalk(String phoneFromAuth, String id) { + UserEntity user = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireParticipant(request, user); + if (request.getStartedAt() == null) { + throw new ApiException(HttpStatus.CONFLICT, "WALK_NOT_STARTED", "Walk is not started"); + } + Instant now = Instant.now(); + if (request.getRequester().getId().equals(user.getId())) { + request.setRequesterFinishedAt(now); + } else { + request.setVolunteerFinishedAt(now); + } + if (request.getRequesterFinishedAt() != null && request.getVolunteerFinishedAt() != null) { + request.setStatus("COMPLETED"); + request.setCompletedAt(now); + UserEntity volunteer = request.getVolunteer(); + volunteer.setTotalPoints(volunteer.getTotalPoints() + WALK_REWARD); + users.save(volunteer); + } + return toHelpResp(requests.save(request), user, true); + } + + private void requireNoActiveRequesterWalk(UserEntity requester, HelpRequestEntity current) { + boolean hasActive = requests.findByRequesterIdAndStatus(requester.getId(), "ACCEPTED").stream() + .anyMatch(request -> isOtherActiveRequesterWalk(request, current)); + if (hasActive) { + throw new ApiException(HttpStatus.CONFLICT, "ACTIVE_WALK_EXISTS", "Requester already has an active volunteer walk"); + } + } + + private void requireNoActiveVolunteerWalk(UserEntity volunteer, HelpRequestEntity current) { + boolean hasActive = requests.findByVolunteerIdAndStatus(volunteer.getId(), "ACCEPTED").stream() + .anyMatch(request -> isOtherActiveVolunteerWalk(request, current)); + if (hasActive) { + throw new ApiException(HttpStatus.CONFLICT, "ACTIVE_WALK_EXISTS", "Volunteer already has an active volunteer walk"); + } + } + + private boolean isOtherActiveRequesterWalk(HelpRequestEntity request, HelpRequestEntity current) { + return !request.getId().equals(current.getId()) + && request.getStartedAt() != null + && request.getRequesterFinishedAt() == null; + } + + private boolean isOtherActiveVolunteerWalk(HelpRequestEntity request, HelpRequestEntity current) { + return !request.getId().equals(current.getId()) + && request.getStartedAt() != null + && request.getVolunteerFinishedAt() == null; + } + + private VolunteerApplicationResp toApplicationResp(VolunteerApplicationEntity app) { + return new VolunteerApplicationResp( + app.getId() == null ? null : app.getId().toString(), + app.getApplicant().getId().toString(), + joinName(app.getApplicant()), + app.getDobroUrl(), + app.getPhone(), + app.getSocialNickname(), + app.getPhotos().stream().map(VolunteerApplicationPhotoEntity::getUrl).toList(), + app.getStatus(), + app.getModeratorComment(), + app.getCreatedAt(), + app.getModeratedAt() + ); + } + + private HelpRequestResp toHelpResp(HelpRequestEntity request, UserEntity viewer, boolean details) { + boolean requester = request.getRequester().getId().equals(viewer.getId()); + boolean assignedVolunteer = request.getVolunteer() != null && request.getVolunteer().getId().equals(viewer.getId()); + boolean contactsVisible = requester || assignedVolunteer; + boolean participant = requester || assignedVolunteer; + boolean canStart = participant + && "ACCEPTED".equals(request.getStatus()) + && scheduledTime(request).minus(Duration.ofMinutes(30)).isBefore(Instant.now()); + return new HelpRequestResp( + request.getId() == null ? null : request.getId().toString(), + request.getRequester().getId().toString(), + request.getVolunteer() == null ? null : request.getVolunteer().getId().toString(), + request.getFromAddress(), + request.getToAddress(), + DATE_FORMAT.format(request.getDate()), + request.getTime().toString(), + contactsVisible ? request.getPhone() : null, + contactsVisible ? request.getSocialNickname() : null, + request.getComment(), + request.getStatus(), + contactsVisible, + canStart, + request.getStartedAt() != null, + "COMPLETED".equals(request.getStatus()), + request.getCreatedAt() + ); + } + + private void fillRequest(HelpRequestEntity request, HelpRequestReq req) { + String fromAddress = InputRules.trimToNull(req.fromAddress()); + String toAddress = InputRules.trimToNull(req.toAddress()); + String comment = InputRules.trimToNull(req.comment()); + if (fromAddress == null) { + throw bad("FROM_ADDRESS_EMPTY", "From address is empty"); + } + if (toAddress == null) { + throw bad("TO_ADDRESS_EMPTY", "To address is empty"); + } + if (comment == null) { + throw bad("COMMENT_EMPTY", "Comment is empty"); + } + String phone = Crypto.normPhone(req.phone()); + if (phone.isEmpty()) { + throw bad("PHONE_INVALID", "Phone is invalid"); + } + request.setFromAddress(fromAddress); + request.setToAddress(toAddress); + request.setDate(parseDate(req.date())); + request.setTime(parseTime(req.time())); + request.setPhone(phone); + request.setSocialNickname(InputRules.trimToNull(req.socialNickname())); + request.setComment(comment); + } + + private void saveWalkRoute(HelpRequestEntity request, WalkRouteReq req) { + List points = readRoutePoints(req); + if (points.size() < 2) { + throw bad("ROUTE_POINTS_INVALID", "Route must contain at least two points"); + } + StringJoiner joiner = new StringJoiner(";"); + for (RoutePointReq point : points) { + validateCoordinates(point.latitude(), point.longitude()); + joiner.add(point.latitude() + "," + point.longitude()); + } + request.setPlannedRoutePoints(joiner.toString()); + } + + private List readRoutePoints(WalkRouteReq req) { + if (req == null) { + throw bad("ROUTE_POINTS_INVALID", "Route is empty"); + } + if (req.routePoints() != null && !req.routePoints().isEmpty()) { + return req.routePoints(); + } + String encoded = InputRules.trimToNull(req.encodedPoints()); + if (encoded == null) { + throw bad("ROUTE_POINTS_INVALID", "Route is empty"); + } + return decodePolyline(encoded); + } + + private List decodePolyline(String encoded) { + List points = new ArrayList<>(); + int index = 0; + int lat = 0; + int lng = 0; + while (index < encoded.length()) { + int[] latResult = decodePolylineValue(encoded, index); + lat += latResult[0]; + index = latResult[1]; + int[] lngResult = decodePolylineValue(encoded, index); + lng += lngResult[0]; + index = lngResult[1]; + points.add(new RoutePointReq(lat / 1e5, lng / 1e5)); + } + return points; + } + + private int[] decodePolylineValue(String encoded, int index) { + int result = 0; + int shift = 0; + int b; + do { + if (index >= encoded.length()) { + throw bad("ROUTE_POINTS_INVALID", "Encoded route is invalid"); + } + b = encoded.charAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + int delta = (result & 1) != 0 ? ~(result >> 1) : result >> 1; + return new int[] { delta, index }; + } + + private void validateCoordinates(Double latitude, Double longitude) { + if (latitude == null || longitude == null + || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + throw bad("LOCATION_INVALID", "Location is invalid"); + } + } + + private UserEntity findCurrent(String phoneFromAuth) { + String phoneNorm = Crypto.normPhone(phoneFromAuth); + if (phoneNorm.isEmpty()) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_PHONE_NOT_FOUND", "User with given phone not found"); + } + return users.findByPhoneHash(Crypto.sha256Hex(phoneNorm)) + .orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_PHONE_NOT_FOUND", "User with given phone not found")); + } + + private UserEntity requireModerator(String phoneFromAuth) { + UserEntity user = findCurrent(phoneFromAuth); + if (!Role.MODERATOR.name().equals(user.getRole()) && !Role.MODERATOR_ADMIN.name().equals(user.getRole())) { + throw new ApiException(HttpStatus.FORBIDDEN, "MODERATOR_REQUIRED", "Moderator rights required"); + } + return user; + } + + private UserEntity requireVolunteer(String phoneFromAuth) { + UserEntity user = findCurrent(phoneFromAuth); + if (!isVolunteer(user)) { + throw new ApiException(HttpStatus.FORBIDDEN, "VOLUNTEER_REQUIRED", "Volunteer rights required"); + } + return user; + } + + private boolean isVolunteer(UserEntity user) { + return Role.VOLUNTEER.name().equals(user.getRole()); + } + + private void requireRequester(HelpRequestEntity request, UserEntity user) { + if (!request.getRequester().getId().equals(user.getId())) { + throw new ApiException(HttpStatus.FORBIDDEN, "REQUEST_OWNER_REQUIRED", "Request owner rights required"); + } + } + + private void requireVolunteerOfRequest(HelpRequestEntity request, UserEntity user) { + if (request.getVolunteer() == null || !request.getVolunteer().getId().equals(user.getId())) { + throw new ApiException(HttpStatus.FORBIDDEN, "REQUEST_VOLUNTEER_REQUIRED", "Request volunteer rights required"); + } + } + + private void requireParticipant(HelpRequestEntity request, UserEntity user) { + if (!isParticipant(request, user)) { + throw new ApiException(HttpStatus.FORBIDDEN, "REQUEST_PARTICIPANT_REQUIRED", "Request participant rights required"); + } + } + + private boolean isParticipant(HelpRequestEntity request, UserEntity user) { + return request.getRequester().getId().equals(user.getId()) + || request.getVolunteer() != null && request.getVolunteer().getId().equals(user.getId()); + } + + private VolunteerApplicationEntity findApplication(String id) { + return applications.findById(parseId(id, "APPLICATION_ID_INVALID", "Application id is invalid")) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "APPLICATION_NOT_FOUND", "Application not found")); + } + + private HelpRequestEntity findRequest(String id) { + return requests.findById(parseId(id, "HELP_REQUEST_ID_INVALID", "Help request id is invalid")) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "HELP_REQUEST_NOT_FOUND", "Help request not found")); + } + + private Long parseId(String raw, String code, String msg) { + try { + return Long.parseLong(raw); + } catch (Exception e) { + throw new ApiException(HttpStatus.BAD_REQUEST, code, msg); + } + } + + private LocalDate parseDate(String raw) { + try { + return LocalDate.parse(raw, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw bad("DATE_INVALID", "Date must have dd-MM-yyyy format"); + } + } + + private LocalTime parseTime(String raw) { + try { + return LocalTime.parse(raw); + } catch (DateTimeParseException e) { + throw bad("TIME_INVALID", "Time must have HH:mm format"); + } + } + + private Instant scheduledTime(HelpRequestEntity request) { + return LocalDateTime.of(request.getDate(), request.getTime()).atZone(ZoneId.systemDefault()).toInstant(); + } + + private String requireUrl(String raw, String code, String msg) { + String value = InputRules.trimToNull(raw); + if (value == null || !(value.startsWith("http://") || value.startsWith("https://"))) { + throw new ApiException(HttpStatus.BAD_REQUEST, code, msg); + } + return value; + } + + private ApiException bad(String code, String msg) { + return new ApiException(HttpStatus.BAD_REQUEST, code, msg); + } + + private String joinName(UserEntity user) { + String first = user.getFirstName() == null ? "" : user.getFirstName(); + String last = user.getLastName() == null ? "" : user.getLastName(); + return (first + " " + last).trim(); + } +} diff --git a/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java b/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java new file mode 100644 index 0000000..72dfa77 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java @@ -0,0 +1,126 @@ +package goodroad.volunteer.repository; + +import goodroad.users.repository.UserEntity; +import jakarta.persistence.*; +import java.time.*; + +@Entity +@Table(name = "help_request") +public class HelpRequestEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "requester_id", nullable = false) + private UserEntity requester; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "volunteer_id") + private UserEntity volunteer; + + @Column(name = "from_address", nullable = false, length = 500) + private String fromAddress; + + @Column(name = "to_address", nullable = false, length = 500) + private String toAddress; + + @Column(name = "walk_date", nullable = false) + private LocalDate date; + + @Column(name = "walk_time", nullable = false) + private LocalTime time; + + @Column(name = "phone", nullable = false, length = 32) + private String phone; + + @Column(name = "social_nickname", length = 120) + private String socialNickname; + + @Column(name = "comment", nullable = false, length = 2000) + private String comment; + + @Column(name = "status", nullable = false, length = 16) + private String status = "OPEN"; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "accepted_at") + private Instant acceptedAt; + + @Column(name = "cancelled_at") + private Instant cancelledAt; + + @Column(name = "requester_started_at") + private Instant requesterStartedAt; + + @Column(name = "volunteer_started_at") + private Instant volunteerStartedAt; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "requester_finished_at") + private Instant requesterFinishedAt; + + @Column(name = "volunteer_finished_at") + private Instant volunteerFinishedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + + @Column(name = "planned_route_points", columnDefinition = "text") + private String plannedRoutePoints; + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public UserEntity getRequester() { return requester; } + public void setRequester(UserEntity requester) { this.requester = requester; } + public UserEntity getVolunteer() { return volunteer; } + public void setVolunteer(UserEntity volunteer) { this.volunteer = volunteer; } + public String getFromAddress() { return fromAddress; } + public void setFromAddress(String fromAddress) { this.fromAddress = fromAddress; } + public String getToAddress() { return toAddress; } + public void setToAddress(String toAddress) { this.toAddress = toAddress; } + public LocalDate getDate() { return date; } + public void setDate(LocalDate date) { this.date = date; } + public LocalTime getTime() { return time; } + public void setTime(LocalTime time) { this.time = time; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getSocialNickname() { return socialNickname; } + public void setSocialNickname(String socialNickname) { this.socialNickname = socialNickname; } + public String getComment() { return comment; } + public void setComment(String comment) { this.comment = comment; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getAcceptedAt() { return acceptedAt; } + public void setAcceptedAt(Instant acceptedAt) { this.acceptedAt = acceptedAt; } + public Instant getCancelledAt() { return cancelledAt; } + public void setCancelledAt(Instant cancelledAt) { this.cancelledAt = cancelledAt; } + public Instant getRequesterStartedAt() { return requesterStartedAt; } + public void setRequesterStartedAt(Instant requesterStartedAt) { this.requesterStartedAt = requesterStartedAt; } + public Instant getVolunteerStartedAt() { return volunteerStartedAt; } + public void setVolunteerStartedAt(Instant volunteerStartedAt) { this.volunteerStartedAt = volunteerStartedAt; } + public Instant getStartedAt() { return startedAt; } + public void setStartedAt(Instant startedAt) { this.startedAt = startedAt; } + public Instant getRequesterFinishedAt() { return requesterFinishedAt; } + public void setRequesterFinishedAt(Instant requesterFinishedAt) { this.requesterFinishedAt = requesterFinishedAt; } + public Instant getVolunteerFinishedAt() { return volunteerFinishedAt; } + public void setVolunteerFinishedAt(Instant volunteerFinishedAt) { this.volunteerFinishedAt = volunteerFinishedAt; } + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } + public String getPlannedRoutePoints() { return plannedRoutePoints; } + public void setPlannedRoutePoints(String plannedRoutePoints) { this.plannedRoutePoints = plannedRoutePoints; } +} diff --git a/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java b/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java new file mode 100644 index 0000000..37ff615 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java @@ -0,0 +1,12 @@ +package goodroad.volunteer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface HelpRequestRepo extends JpaRepository { + List findByRequesterIdOrderByDateDescTimeDescCreatedAtDesc(Long requesterId); + List findByVolunteerIdOrderByDateDescTimeDescCreatedAtDesc(Long volunteerId); + List findByStatusOrderByDateAscTimeAscCreatedAtAsc(String status); + List findByRequesterIdAndStatus(Long requesterId, String status); + List findByVolunteerIdAndStatus(Long volunteerId, String status); +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerApplicationEntity.java b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationEntity.java new file mode 100644 index 0000000..6064220 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationEntity.java @@ -0,0 +1,77 @@ +package goodroad.volunteer.repository; + +import goodroad.users.repository.UserEntity; +import jakarta.persistence.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "volunteer_application") +public class VolunteerApplicationEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "applicant_id", nullable = false) + private UserEntity applicant; + + @Column(name = "dobro_url", nullable = false, length = 512) + private String dobroUrl; + + @Column(name = "phone", nullable = false, length = 32) + private String phone; + + @Column(name = "social_nickname", length = 120) + private String socialNickname; + + @Column(name = "status", nullable = false, length = 16) + private String status = "PENDING"; + + @Column(name = "moderator_comment", length = 1000) + private String moderatorComment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moderator_id") + private UserEntity moderator; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "moderated_at") + private Instant moderatedAt; + + @OneToMany(mappedBy = "application", cascade = CascadeType.ALL, orphanRemoval = true) + private List photos = new ArrayList<>(); + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public UserEntity getApplicant() { return applicant; } + public void setApplicant(UserEntity applicant) { this.applicant = applicant; } + public String getDobroUrl() { return dobroUrl; } + public void setDobroUrl(String dobroUrl) { this.dobroUrl = dobroUrl; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getSocialNickname() { return socialNickname; } + public void setSocialNickname(String socialNickname) { this.socialNickname = socialNickname; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getModeratorComment() { return moderatorComment; } + public void setModeratorComment(String moderatorComment) { this.moderatorComment = moderatorComment; } + public UserEntity getModerator() { return moderator; } + public void setModerator(UserEntity moderator) { this.moderator = moderator; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getModeratedAt() { return moderatedAt; } + public void setModeratedAt(Instant moderatedAt) { this.moderatedAt = moderatedAt; } + public List getPhotos() { return photos; } + public void setPhotos(List photos) { this.photos = photos; } +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoEntity.java b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoEntity.java new file mode 100644 index 0000000..9150973 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoEntity.java @@ -0,0 +1,38 @@ +package goodroad.volunteer.repository; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "volunteer_application_photo") +public class VolunteerApplicationPhotoEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "application_id", nullable = false) + private VolunteerApplicationEntity application; + + @Column(name = "url", nullable = false, length = 512) + private String url; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public VolunteerApplicationEntity getApplication() { return application; } + public void setApplication(VolunteerApplicationEntity application) { this.application = application; } + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoRepo.java b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoRepo.java new file mode 100644 index 0000000..2e616c1 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoRepo.java @@ -0,0 +1,6 @@ +package goodroad.volunteer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VolunteerApplicationPhotoRepo extends JpaRepository { +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerApplicationRepo.java b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationRepo.java new file mode 100644 index 0000000..50193ad --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerApplicationRepo.java @@ -0,0 +1,10 @@ +package goodroad.volunteer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface VolunteerApplicationRepo extends JpaRepository { + Optional findFirstByApplicantIdOrderByCreatedAtDesc(Long applicantId); + List findByStatusOrderByCreatedAtAsc(String status); +} diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql index 6ee46bb..91718ee 100644 --- a/src/main/resources/db/migration/V1__init_schema.sql +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -4,7 +4,7 @@ create table if not exists users ( last_name varchar(80), phone_hash varchar(64) not null unique, role varchar(16) not null - check (role in ('USER', 'MODERATOR', 'MODERATOR_ADMIN')), + check (role in ('USER', 'VOLUNTEER', 'MODERATOR', 'MODERATOR_ADMIN')), password_hash varchar(100) not null, photo_url varchar(512), is_active boolean not null default true, diff --git a/src/main/resources/db/migration/V2__volunteer_schema.sql b/src/main/resources/db/migration/V2__volunteer_schema.sql new file mode 100644 index 0000000..b9cadc2 --- /dev/null +++ b/src/main/resources/db/migration/V2__volunteer_schema.sql @@ -0,0 +1,62 @@ +alter table users drop constraint if exists users_role_check; +alter table users add constraint users_role_check + check (role in ('USER', 'VOLUNTEER', 'MODERATOR', 'MODERATOR_ADMIN')); + +create table if not exists volunteer_application ( + id bigint generated by default as identity primary key, + applicant_id bigint not null references users(id) on delete cascade, + dobro_url varchar(512) not null, + phone varchar(32) not null, + social_nickname varchar(120), + status varchar(16) not null check (status in ('PENDING', 'APPROVED', 'REJECTED')), + moderator_comment varchar(1000), + moderator_id bigint references users(id) on delete set null, + created_at timestamptz not null default now(), + moderated_at timestamptz +); + +create index if not exists ix_volunteer_application_applicant_created + on volunteer_application(applicant_id, created_at desc); +create index if not exists ix_volunteer_application_status_created + on volunteer_application(status, created_at asc); + +create table if not exists volunteer_application_photo ( + id bigint generated by default as identity primary key, + application_id bigint not null references volunteer_application(id) on delete cascade, + url varchar(512) not null, + created_at timestamptz not null default now() +); + +create index if not exists ix_volunteer_application_photo_application + on volunteer_application_photo(application_id); + +create table if not exists help_request ( + id bigint generated by default as identity primary key, + requester_id bigint not null references users(id) on delete cascade, + volunteer_id bigint references users(id) on delete set null, + from_address varchar(500) not null, + to_address varchar(500) not null, + walk_date date not null, + walk_time time not null, + phone varchar(32) not null, + social_nickname varchar(120), + comment varchar(2000) not null, + status varchar(16) not null check (status in ('OPEN', 'ACCEPTED', 'CANCELLED', 'COMPLETED')), + created_at timestamptz not null default now(), + accepted_at timestamptz, + cancelled_at timestamptz, + requester_started_at timestamptz, + volunteer_started_at timestamptz, + started_at timestamptz, + requester_finished_at timestamptz, + volunteer_finished_at timestamptz, + completed_at timestamptz, + planned_route_points text +); + +create index if not exists ix_help_request_requester_date + on help_request(requester_id, walk_date desc, walk_time desc, created_at desc); +create index if not exists ix_help_request_volunteer_date + on help_request(volunteer_id, walk_date desc, walk_time desc, created_at desc); +create index if not exists ix_help_request_status_date + on help_request(status, walk_date asc, walk_time asc, created_at asc); diff --git a/src/test/java/goodroad/reviews/UserReviewServiceTest.java b/src/test/java/goodroad/reviews/UserReviewServiceTest.java index 2a93b90..6a07c0a 100644 --- a/src/test/java/goodroad/reviews/UserReviewServiceTest.java +++ b/src/test/java/goodroad/reviews/UserReviewServiceTest.java @@ -8,14 +8,13 @@ import goodroad.storage.StorageService; import goodroad.users.repository.UserEntity; import goodroad.users.repository.UserRepo; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; -import java.time.Instant; import java.util.List; import java.util.Optional; @@ -46,31 +45,66 @@ class UserReviewServiceTest { @Mock private StorageService storageService; - @InjectMocks private UserReviewService service; + @BeforeEach + void setUp() { + ReviewValidationService validator = new ReviewValidationService(); + ReviewFeatureService featureService = new ReviewFeatureService(features); + ReviewMapper mapper = new ReviewMapper(reviewSupport, photos, reviewObstacles); + + service = new UserReviewService( + users, + reviews, + reviewSupport, + storageService, + validator, + featureService, + mapper + ); + } + @Test void shouldCreateReview() { UserEntity user = user(1L); ObstacleFeatureEntity feature = feature(10L); + when(users.findByPhoneHash(anyString())).thenReturn(Optional.of(user)); - when(features.findByAddressAndType(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any())) - .thenReturn(Optional.of(feature)); - when(reviews.findByFeatureIdAndAuthorId(10L, 1L)).thenReturn(Optional.empty()); - when(reviews.save(any(ObstacleReviewEntity.class))).thenAnswer(invocation -> { - ObstacleReviewEntity review = invocation.getArgument(0); - review.setId(20L); - return review; - }); - when(reviewSupport.loadBundle(anyList())).thenReturn(bundle(feature)); - - UserReviewService.ReviewCardResp result = service.createReview("+79990000001", request()); + + when(features.findByAddressAndType( + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + any() + )).thenReturn(Optional.of(feature)); + + when(reviews.findByFeatureIdAndAuthorId(10L, 1L)) + .thenReturn(Optional.empty()); + + when(reviews.save(any(ObstacleReviewEntity.class))) + .thenAnswer(invocation -> { + ObstacleReviewEntity review = invocation.getArgument(0); + review.setId(20L); + return review; + }); + + when(reviewSupport.loadBundle(anyList())) + .thenReturn(bundle(feature)); + + UserReviewService.ReviewCardResp result = + service.createReview("+79990000001", request()); assertEquals("20", result.id()); assertEquals("10", result.featureId()); assertEquals("PENDING", result.status()); + verify(reviewObstacles, times(ObstacleType.allNames().size())) .save(any(ObstacleReviewObstacleEntity.class)); + verify(photos).save(any(ObstacleReviewPhotoEntity.class)); } @@ -78,22 +112,39 @@ void shouldCreateReview() { void shouldRejectDuplicateReview() { UserEntity user = user(1L); ObstacleFeatureEntity feature = feature(10L); + when(users.findByPhoneHash(anyString())).thenReturn(Optional.of(user)); - when(features.findByAddressAndType(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any())) - .thenReturn(Optional.of(feature)); - when(reviews.findByFeatureIdAndAuthorId(10L, 1L)).thenReturn(Optional.of(new ObstacleReviewEntity())); - assertThrows(RuntimeException.class, () -> service.createReview("+79990000001", request())); + when(features.findByAddressAndType( + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + anyString(), + any() + )).thenReturn(Optional.of(feature)); + + when(reviews.findByFeatureIdAndAuthorId(10L, 1L)) + .thenReturn(Optional.of(new ObstacleReviewEntity())); + + assertThrows( + RuntimeException.class, + () -> service.createReview("+79990000001", request()) + ); } @Test void shouldReturnReviewPoints() { UserEntity user = user(1L); user.setTotalPoints(20); + when(users.findByPhoneHash(anyString())).thenReturn(Optional.of(user)); when(reviews.countByAuthorIdAndStatus(1L, "APPROVED")).thenReturn(2L); - UserReviewService.ReviewPointsResp result = service.getOwnReviewPoints("+79990000001"); + UserReviewService.ReviewPointsResp result = + service.getOwnReviewPoints("+79990000001"); assertEquals(20, result.totalPoints()); assertEquals(2, result.approvedReviews()); @@ -102,13 +153,19 @@ void shouldReturnReviewPoints() { @Test void shouldUploadReviewPhoto() { UserEntity user = user(1L); + MockMultipartFile file = new MockMultipartFile( - "file", "review.png", "image/png", new byte[] {1, 2, 3} + "file", + "review.png", + "image/png", + new byte[] {1, 2, 3} ); + when(users.findByPhoneHash(anyString())).thenReturn(Optional.of(user)); when(storageService.uploadReviewPhoto(file, "1")).thenReturn("http://photo"); - UserReviewService.ReviewPhotoUploadResp result = service.uploadReviewPhoto("+79990000001", file); + UserReviewService.ReviewPhotoUploadResp result = + service.uploadReviewPhoto("+79990000001", file); assertEquals("http://photo", result.photoUrl()); } @@ -118,7 +175,13 @@ private UserReviewService.UpsertReviewReq request() { 59.93, 30.33, new UserReviewService.AddressReq( - "Россия", "Санкт-Петербург", "город", "Санкт-Петербург", "Садовая", "12", null + "Россия", + "Санкт-Петербург", + "город", + "Санкт-Петербург", + "Садовая", + "12", + null ), (short) 4, List.of(new UserReviewService.ObstacleSeverityItem("STAIRS", (short) 3)), @@ -134,6 +197,7 @@ private UserEntity user(Long id) { .active(true) .totalPoints(0) .build(); + user.setId(id); return user; } @@ -150,6 +214,7 @@ private ObstacleFeatureEntity feature(Long id) { .street("Садовая") .house("12") .build(); + feature.setId(id); return feature; } @@ -158,7 +223,10 @@ private ReviewSupportService.ReviewBundle bundle(ObstacleFeatureEntity feature) return new ReviewSupportService.ReviewBundle( java.util.Map.of(feature.getId(), feature), java.util.Map.of(20L, List.of("http://photo")), - java.util.Map.of(20L, List.of(new ReviewSupportService.ReviewObstacleItem("STAIRS", (short) 3))) + java.util.Map.of( + 20L, + List.of(new ReviewSupportService.ReviewObstacleItem("STAIRS", (short) 3)) + ) ); } -} +} \ No newline at end of file diff --git a/src/test/java/goodroad/volunteer/VolunteerControllerTest.java b/src/test/java/goodroad/volunteer/VolunteerControllerTest.java new file mode 100644 index 0000000..f0233c4 --- /dev/null +++ b/src/test/java/goodroad/volunteer/VolunteerControllerTest.java @@ -0,0 +1,248 @@ +package goodroad.volunteer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.security.Principal; +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +@ExtendWith(MockitoExtension.class) +class VolunteerControllerTest { + + @Mock + private VolunteerService service; + + @Test + void shouldReturnVolunteerMenu() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); + when(service.getMenu("+79990000001")) + .thenReturn(new VolunteerService.VolunteerMenuResp(true, "APPROVED", null)); + + mvc.perform(get("/volunteer/menu").principal(principal("+79990000001"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.volunteer").value(true)) + .andExpect(jsonPath("$.applicationStatus").value("APPROVED")); + + verify(service).getMenu("+79990000001"); + } + + @Test + void shouldCreateVolunteerApplication() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); + when(service.createApplication(eq("+79990000001"), any(VolunteerService.CreateVolunteerApplicationReq.class))) + .thenReturn(application("10", "PENDING", null)); + + mvc.perform(post("/volunteer/applications") + .principal(principal("+79990000001")) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "dobroUrl": "https://dobro.ru/volunteer/1", + "phone": "+79990000001", + "socialNickname": "@volunteer", + "certificatePhotoUrls": [ + "https://storage.yandexcloud.net/bucket/cert.jpg" + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("10")) + .andExpect(jsonPath("$.status").value("PENDING")); + + verify(service).createApplication(eq("+79990000001"), argThat(req -> + "https://dobro.ru/volunteer/1".equals(req.dobroUrl()) + && "+79990000001".equals(req.phone()) + && "@volunteer".equals(req.socialNickname()) + && req.certificatePhotoUrls().size() == 1 + )); + } + + @Test + void shouldUploadVolunteerCertificate() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); + MockMultipartFile file = new MockMultipartFile( + "file", "cert.png", "image/png", new byte[] {1, 2, 3} + ); + when(service.uploadCertificate(eq("+79990000001"), any())) + .thenReturn(new VolunteerService.PhotoUploadResp("https://storage/cert.png")); + + mvc.perform(multipart("/volunteer/applications/photos") + .file(file) + .principal(principal("+79990000001"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photoUrl").value("https://storage/cert.png")); + + verify(service).uploadCertificate(eq("+79990000001"), any()); + } + + @Test + void shouldUseHelpRequestEndpoints() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); + Principal user = principal("+79990000001"); + VolunteerService.HelpRequestResp request = helpRequest("20", null, "OPEN", false, false, false); + VolunteerService.HelpRequestResp accepted = helpRequest("20", "2", "ACCEPTED", true, false, false); + + when(service.createHelpRequest(eq("+79990000001"), any(VolunteerService.HelpRequestReq.class))).thenReturn(request); + when(service.listOwnRequests("+79990000001")).thenReturn(List.of(request)); + when(service.getHelpRequest("+79990000001", "20")).thenReturn(request); + when(service.cancelOwnRequest("+79990000001", "20")).thenReturn(request); + + mvc.perform(post("/volunteer/requests") + .principal(user) + .contentType(MediaType.APPLICATION_JSON) + .content(helpRequestJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("20")) + .andExpect(jsonPath("$.contactsVisible").value(false)); + + mvc.perform(get("/volunteer/requests/own").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("20")); + + mvc.perform(get("/volunteer/requests/20").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("OPEN")); + + mvc.perform(post("/volunteer/requests/20/cancel").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("20")); + + mvc.perform(delete("/volunteer/requests/20").principal(user)) + .andExpect(status().isOk()); + + verify(service).deleteOwnRequest("+79990000001", "20"); + verify(service).createHelpRequest(eq("+79990000001"), argThat(req -> + "Садовая, 10".equals(req.fromAddress()) + && "Невский, 20".equals(req.toAddress()) + && "24-05-2026".equals(req.date()) + && "13:30".equals(req.time()) + )); + + when(service.acceptRequest("+79990000001", "20")).thenReturn(accepted); + when(service.withdrawResponse("+79990000001", "20")).thenReturn(request); + when(service.listAvailableRequests("+79990000001", 59.93, 30.31)).thenReturn(List.of(request)); + when(service.listMyWards("+79990000001")).thenReturn(List.of(accepted)); + + mvc.perform(get("/volunteer/requests/available") + .principal(user) + .param("latitude", "59.93") + .param("longitude", "30.31")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("20")); + + mvc.perform(get("/volunteer/requests/my-wards").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].volunteerId").value("2")); + + mvc.perform(post("/volunteer/requests/20/accept").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.contactsVisible").value(true)); + + mvc.perform(post("/volunteer/requests/20/withdraw").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("OPEN")); + } + + @Test + void shouldUseWalkEndpoints() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); + Principal user = principal("+79990000001"); + VolunteerService.HelpRequestResp active = helpRequest("20", "2", "ACCEPTED", true, true, false); + VolunteerService.HelpRequestResp completed = helpRequest("20", "2", "COMPLETED", true, true, true); + + when(service.setWalkRoute(eq("+79990000001"), eq("20"), any(VolunteerService.WalkRouteReq.class))).thenReturn(active); + when(service.startWalk(eq("+79990000001"), eq("20"), any(VolunteerService.WalkRouteReq.class))).thenReturn(active); + when(service.finishWalk("+79990000001", "20")).thenReturn(completed); + + mvc.perform(post("/volunteer/requests/20/route") + .principal(user) + .contentType(MediaType.APPLICATION_JSON) + .content(routeJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.started").value(true)); + + mvc.perform(post("/volunteer/requests/20/start") + .principal(user) + .contentType(MediaType.APPLICATION_JSON) + .content(routeJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACCEPTED")); + + mvc.perform(post("/volunteer/requests/20/finish").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.completed").value(true)); + } + + private Principal principal(String phone) { + return new UsernamePasswordAuthenticationToken(phone, "password"); + } + + private VolunteerService.VolunteerApplicationResp application(String id, String status, String comment) { + return new VolunteerService.VolunteerApplicationResp( + id, "1", "Иван Петров", "https://dobro.ru/volunteer/1", "79990000001", "@volunteer", + List.of("https://storage/cert.png"), status, comment, + Instant.parse("2026-05-01T10:00:00Z"), null + ); + } + + private VolunteerService.HelpRequestResp helpRequest( + String id, + String volunteerId, + String status, + boolean contactsVisible, + boolean started, + boolean completed + ) { + return new VolunteerService.HelpRequestResp( + id, "1", volunteerId, "Садовая, 10", "Невский, 20", "24-05-2026", "13:30", + contactsVisible ? "79990000001" : null, + contactsVisible ? "@user" : null, + "Нужно помочь дойти до метро", status, contactsVisible, true, started, completed, + Instant.parse("2026-05-01T10:00:00Z") + ); + } + + private String helpRequestJson() { + return """ + { + "fromAddress": "Садовая, 10", + "toAddress": "Невский, 20", + "date": "24-05-2026", + "time": "13:30", + "phone": "+79990000001", + "socialNickname": "@user", + "comment": "Нужно помочь дойти до метро" + } + """; + } + + private String routeJson() { + return """ + { + "routePoints": [ + { + "latitude": 59.93, + "longitude": 30.31 + }, + { + "latitude": 59.94, + "longitude": 30.32 + } + ] + } + """; + } +} diff --git a/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java b/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java new file mode 100644 index 0000000..dd579de --- /dev/null +++ b/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java @@ -0,0 +1,77 @@ +package goodroad.volunteer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.security.Principal; +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +@ExtendWith(MockitoExtension.class) +class VolunteerModerationControllerTest { + + @Mock + private VolunteerService service; + + @Test + void shouldModerateVolunteerApplications() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerModerationController(service)).build(); + Principal moderator = principal("+79990000151"); + VolunteerService.VolunteerApplicationResp pending = application("10", "PENDING", null); + VolunteerService.VolunteerApplicationResp approved = application("10", "APPROVED", null); + VolunteerService.VolunteerApplicationResp rejected = application("10", "REJECTED", "Не хватает сертификатов"); + + when(service.listPendingApplications()).thenReturn(List.of(pending)); + when(service.approveApplication("+79990000151", "10")).thenReturn(approved); + when(service.rejectApplication(eq("+79990000151"), eq("10"), any(VolunteerService.RejectApplicationReq.class))) + .thenReturn(rejected); + + mvc.perform(get("/volunteer/moderation/applications/pending").principal(moderator)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("10")) + .andExpect(jsonPath("$[0].status").value("PENDING")); + + mvc.perform(post("/volunteer/moderation/applications/10/approve").principal(moderator)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("APPROVED")); + + mvc.perform(post("/volunteer/moderation/applications/10/reject") + .principal(moderator) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "reason": "Не хватает сертификатов" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("REJECTED")) + .andExpect(jsonPath("$.moderatorComment").value("Не хватает сертификатов")); + + verify(service).rejectApplication(eq("+79990000151"), eq("10"), argThat(req -> + "Не хватает сертификатов".equals(req.reason()) + )); + } + + private Principal principal(String phone) { + return new UsernamePasswordAuthenticationToken(phone, "password"); + } + + private VolunteerService.VolunteerApplicationResp application(String id, String status, String comment) { + return new VolunteerService.VolunteerApplicationResp( + id, "1", "Иван Петров", "https://dobro.ru/volunteer/1", "79990000001", "@volunteer", + List.of("https://storage/cert.png"), status, comment, + Instant.parse("2026-05-01T10:00:00Z"), null + ); + } +} diff --git a/src/test/java/goodroad/volunteer/VolunteerServiceTest.java b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java new file mode 100644 index 0000000..21b3611 --- /dev/null +++ b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java @@ -0,0 +1,323 @@ +package goodroad.volunteer; + +import goodroad.api.ApiErrors.ApiException; +import goodroad.security.Crypto; +import goodroad.storage.StorageService; +import goodroad.users.repository.UserEntity; +import goodroad.users.repository.UserRepo; +import goodroad.volunteer.repository.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VolunteerServiceTest { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + @Mock + private UserRepo users; + + @Mock + private VolunteerApplicationRepo applications; + + @Mock + private VolunteerApplicationPhotoRepo applicationPhotos; + + @Mock + private HelpRequestRepo requests; + + @Mock + private StorageService storageService; + + @InjectMocks + private VolunteerService service; + + @Test + void shouldCreateVolunteerApplicationWithCertificateLinks() { + UserEntity user = user(1L, "USER", "+79990000001"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(user)); + when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.empty()); + when(applications.save(any(VolunteerApplicationEntity.class))).thenAnswer(invocation -> { + VolunteerApplicationEntity application = invocation.getArgument(0); + application.setId(10L); + application.setCreatedAt(Instant.now()); + return application; + }); + + VolunteerService.VolunteerApplicationResp result = service.createApplication( + "+79990000001", + new VolunteerService.CreateVolunteerApplicationReq( + "https://dobro.ru/volunteer/1", + "+79990000001", + "@volunteer", + List.of("https://storage.yandexcloud.net/bucket/cert-1.jpg") + ) + ); + + assertEquals("10", result.id()); + assertEquals("PENDING", result.status()); + assertEquals("79990000001", result.phone()); + assertEquals(List.of("https://storage.yandexcloud.net/bucket/cert-1.jpg"), result.certificatePhotoUrls()); + } + + @Test + void shouldApproveApplicationAndPromoteUserToVolunteer() { + UserEntity applicant = user(1L, "USER", "+79990000001"); + UserEntity moderator = user(2L, "MODERATOR", "+79990000002"); + VolunteerApplicationEntity application = application(20L, applicant, "PENDING"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(moderator)); + when(applications.findById(20L)).thenReturn(Optional.of(application)); + when(applications.save(application)).thenReturn(application); + + VolunteerService.VolunteerApplicationResp result = service.approveApplication("+79990000002", "20"); + + assertEquals("APPROVED", result.status()); + assertEquals("VOLUNTEER", applicant.getRole()); + assertNotNull(application.getModeratedAt()); + verify(users).save(applicant); + } + + @Test + void shouldRejectApplicationWithModeratorReason() { + UserEntity applicant = user(1L, "USER", "+79990000001"); + UserEntity moderator = user(2L, "MODERATOR", "+79990000002"); + VolunteerApplicationEntity application = application(20L, applicant, "PENDING"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(moderator)); + when(applications.findById(20L)).thenReturn(Optional.of(application)); + when(applications.save(application)).thenReturn(application); + + VolunteerService.VolunteerApplicationResp result = service.rejectApplication( + "+79990000002", + "20", + new VolunteerService.RejectApplicationReq("Не хватает сертификата") + ); + + assertEquals("REJECTED", result.status()); + assertEquals("Не хватает сертификата", result.moderatorComment()); + assertEquals(moderator, application.getModerator()); + } + + @Test + void shouldUploadVolunteerCertificateToStorage() { + UserEntity user = user(1L, "USER", "+79990000001"); + MockMultipartFile file = new MockMultipartFile("file", "cert.png", "image/png", new byte[] {1, 2, 3}); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(user)); + when(storageService.uploadVolunteerCertificate(file, "1")).thenReturn("https://storage.yandexcloud.net/bucket/cert.png"); + + VolunteerService.PhotoUploadResp result = service.uploadCertificate("+79990000001", file); + + assertEquals("https://storage.yandexcloud.net/bucket/cert.png", result.photoUrl()); + } + + @Test + void shouldCreateHelpRequestAndHideContactsForNonParticipantVolunteer() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(requester)); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.save(any(HelpRequestEntity.class))).thenAnswer(invocation -> { + HelpRequestEntity request = invocation.getArgument(0); + request.setId(30L); + request.setCreatedAt(Instant.now()); + return request; + }); + when(requests.findById(30L)).thenReturn(Optional.of(helpRequest(30L, requester, null, "OPEN"))); + + VolunteerService.HelpRequestResp created = service.createHelpRequest("+79990000001", helpRequestReq()); + VolunteerService.HelpRequestResp details = service.getHelpRequest("+79990000002", "30"); + + assertEquals("30", created.id()); + assertEquals("OPEN", created.status()); + assertNull(details.phone()); + assertNull(details.socialNickname()); + assertFalse(details.contactsVisible()); + } + + @Test + void shouldAcceptRequestAndShowContactsToVolunteer() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + HelpRequestEntity request = helpRequest(30L, requester, null, "OPEN"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.save(request)).thenReturn(request); + + VolunteerService.HelpRequestResp result = service.acceptRequest("+79990000002", "30"); + + assertEquals("ACCEPTED", result.status()); + assertEquals("2", result.volunteerId()); + assertEquals("79990000001", result.phone()); + assertEquals("@requester", result.socialNickname()); + assertTrue(result.contactsVisible()); + } + + @Test + void shouldWithdrawResponseWithoutPenalty() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + volunteer.setTotalPoints(80); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.save(request)).thenReturn(request); + + VolunteerService.HelpRequestResp result = service.withdrawResponse("+79990000002", "30"); + + assertEquals("OPEN", result.status()); + assertNull(request.getVolunteer()); + assertEquals(80, volunteer.getTotalPoints()); + verify(users, never()).save(volunteer); + } + + @Test + void shouldStartWalkOnlyAfterBothParticipantsConfirm() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + request.setDate(LocalDate.now()); + request.setTime(LocalTime.now().minusMinutes(10)); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(requester)); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.findByRequesterIdAndStatus(1L, "ACCEPTED")).thenReturn(List.of()); + when(requests.findByVolunteerIdAndStatus(2L, "ACCEPTED")).thenReturn(List.of()); + when(requests.save(request)).thenReturn(request); + + VolunteerService.HelpRequestResp afterRequester = service.startWalk("+79990000001", "30", routeReq()); + VolunteerService.HelpRequestResp afterVolunteer = service.startWalk("+79990000002", "30", null); + + assertFalse(afterRequester.started()); + assertTrue(afterVolunteer.started()); + assertNotNull(request.getStartedAt()); + assertEquals("59.93,30.31;59.93,30.32", request.getPlannedRoutePoints()); + } + + @Test + void shouldBlockRequesterFromStartingNewWalkBeforeStoppingPreviousOne() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity oldVolunteer = user(2L, "VOLUNTEER", "+79990000002"); + UserEntity newVolunteer = user(3L, "VOLUNTEER", "+79990000003"); + HelpRequestEntity active = helpRequest(30L, requester, oldVolunteer, "ACCEPTED"); + active.setStartedAt(Instant.now().minus(Duration.ofHours(1))); + HelpRequestEntity next = helpRequest(31L, requester, newVolunteer, "ACCEPTED"); + + when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(requester)); + when(requests.findById(31L)).thenReturn(Optional.of(next)); + when(requests.findByRequesterIdAndStatus(1L, "ACCEPTED")).thenReturn(List.of(active, next)); + + ApiException ex = assertThrows(ApiException.class, () -> service.startWalk("+79990000001", "31", null)); + assertEquals("ACTIVE_WALK_EXISTS", ex.code()); + } + + @Test + void shouldBlockVolunteerFromStartingNewWalkBeforeStoppingPreviousOne() { + UserEntity firstRequester = user(1L, "USER", "+79990000001"); + UserEntity secondRequester = user(3L, "USER", "+79990000003"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + HelpRequestEntity active = helpRequest(30L, firstRequester, volunteer, "ACCEPTED"); + active.setStartedAt(Instant.now().minus(Duration.ofHours(1))); + HelpRequestEntity next = helpRequest(31L, secondRequester, volunteer, "ACCEPTED"); + + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(31L)).thenReturn(Optional.of(next)); + when(requests.findByVolunteerIdAndStatus(2L, "ACCEPTED")).thenReturn(List.of(active, next)); + + ApiException ex = assertThrows(ApiException.class, () -> service.startWalk("+79990000002", "31", null)); + assertEquals("ACTIVE_WALK_EXISTS", ex.code()); + } + + @Test + void shouldCompleteWalkAndAddVolunteerPointsAfterBothParticipantsFinish() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + request.setStartedAt(Instant.now().minus(Duration.ofMinutes(30))); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000001"))).thenReturn(Optional.of(requester)); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.save(request)).thenReturn(request); + + VolunteerService.HelpRequestResp afterRequester = service.finishWalk("+79990000001", "30"); + VolunteerService.HelpRequestResp afterVolunteer = service.finishWalk("+79990000002", "30"); + + assertFalse(afterRequester.completed()); + assertTrue(afterVolunteer.completed()); + assertEquals("COMPLETED", request.getStatus()); + assertEquals(100, volunteer.getTotalPoints()); + verify(users).save(volunteer); + } + + private VolunteerService.HelpRequestReq helpRequestReq() { + return new VolunteerService.HelpRequestReq( + "Садовая улица, 12", + "Невский проспект, 1", + DATE_FORMAT.format(LocalDate.now().plusDays(1)), + "12:30", + "+79990000001", + "@requester", + "Нужно помочь дойти до остановки" + ); + } + + private VolunteerService.WalkRouteReq routeReq() { + return new VolunteerService.WalkRouteReq(null, List.of( + new VolunteerService.RoutePointReq(59.93, 30.31), + new VolunteerService.RoutePointReq(59.93, 30.32) + )); + } + + private UserEntity user(Long id, String role, String phone) { + UserEntity user = UserEntity.builder() + .firstName("Иван") + .lastName("Петров") + .phoneHash(Crypto.sha256Hex(Crypto.normPhone(phone))) + .role(role) + .active(true) + .totalPoints(0) + .build(); + user.setId(id); + return user; + } + + private VolunteerApplicationEntity application(Long id, UserEntity applicant, String status) { + VolunteerApplicationEntity application = new VolunteerApplicationEntity(); + application.setId(id); + application.setApplicant(applicant); + application.setDobroUrl("https://dobro.ru/volunteer/1"); + application.setPhone("79990000001"); + application.setSocialNickname("@volunteer"); + application.setStatus(status); + application.setCreatedAt(Instant.parse("2026-05-01T10:00:00Z")); + return application; + } + + private HelpRequestEntity helpRequest(Long id, UserEntity requester, UserEntity volunteer, String status) { + HelpRequestEntity request = new HelpRequestEntity(); + request.setId(id); + request.setRequester(requester); + request.setVolunteer(volunteer); + request.setFromAddress("Садовая улица, 12"); + request.setToAddress("Невский проспект, 1"); + request.setDate(LocalDate.now().plusDays(1)); + request.setTime(LocalTime.of(12, 30)); + request.setPhone("79990000001"); + request.setSocialNickname("@requester"); + request.setComment("Нужно помочь дойти до остановки"); + request.setStatus(status); + request.setCreatedAt(Instant.parse("2026-05-01T10:00:00Z")); + return request; + } +}