From 1cfcd45183c926fe2cb6302d174aa48aeb801396 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:16:08 +0300 Subject: [PATCH 01/16] feat: add volunteering database schema --- build.gradle | 2 + src/main/java/goodroad/model/Role.java | 1 + .../goodroad/security/SecurityConfig.java | 1 + .../db/migration/V1__init_schema.sql | 2 +- .../db/migration/V2__volunteer_schema.sql | 109 ++++++++++++++++++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V2__volunteer_schema.sql 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/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/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..c38eb8c --- /dev/null +++ b/src/main/resources/db/migration/V2__volunteer_schema.sql @@ -0,0 +1,109 @@ +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 volunteer_user_state ( + user_id bigint primary key references users(id) on delete cascade, + volunteer_warnings integer not null default 0 check (volunteer_warnings between 0 and 3), + requester_warnings integer not null default 0 check (requester_warnings between 0 and 3), + volunteer_banned_until timestamptz +); + +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, + requester_latitude double precision check (requester_latitude is null or requester_latitude between -90 and 90), + requester_longitude double precision check (requester_longitude is null or requester_longitude between -180 and 180), + volunteer_latitude double precision check (volunteer_latitude is null or volunteer_latitude between -90 and 90), + volunteer_longitude double precision check (volunteer_longitude is null or volunteer_longitude between -180 and 180), + 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); + +create table if not exists volunteer_complaint ( + id bigint generated by default as identity primary key, + request_id bigint not null references help_request(id) on delete cascade, + author_id bigint not null references users(id) on delete cascade, + target_id bigint not null references users(id) on delete cascade, + text varchar(2000) not null, + status varchar(16) not null check (status in ('PENDING', 'RESOLVED')), + guilty_user_id bigint references users(id) on delete set null, + moderator_id bigint references users(id) on delete set null, + moderator_comment varchar(1000), + created_at timestamptz not null default now(), + resolved_at timestamptz +); + +create index if not exists ix_volunteer_complaint_status_created + on volunteer_complaint(status, created_at asc); + +create table if not exists volunteer_sos_notification ( + id bigint generated by default as identity primary key, + request_id bigint not null references help_request(id) on delete cascade, + sender_id bigint not null references users(id) on delete cascade, + reason varchar(64) not null, + comment varchar(1000), + status varchar(16) not null default 'OPEN' + check (status in ('OPEN', 'CONFIRMED', 'FALSE_ALARM', 'RESOLVED')), + moderator_id bigint references users(id) on delete set null, + moderator_comment varchar(1000), + created_at timestamptz not null default now(), + resolved_at timestamptz +); + +create index if not exists ix_volunteer_sos_created + on volunteer_sos_notification(created_at desc); +create index if not exists ix_volunteer_sos_status_created + on volunteer_sos_notification(status, created_at desc); From ce0562da8bff6f73913eeccc8cec0397e7bce526 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:18:00 +0300 Subject: [PATCH 02/16] feat: add volunteer certificate upload --- .../java/goodroad/storage/StorageService.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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("."); From 7d09680bff279d6bc4e34ad0d551953b7e4f98ff Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:18:45 +0300 Subject: [PATCH 03/16] feat: add volunteer application persistence --- .../VolunteerApplicationEntity.java | 77 +++++++++++++++++++ .../VolunteerApplicationPhotoEntity.java | 38 +++++++++ .../VolunteerApplicationPhotoRepo.java | 6 ++ .../repository/VolunteerApplicationRepo.java | 10 +++ 4 files changed, 131 insertions(+) create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerApplicationEntity.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoEntity.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerApplicationPhotoRepo.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerApplicationRepo.java 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); +} From 06c4a0a01e0a487f02cde07e5a67f0bc0eae50b2 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:19:16 +0300 Subject: [PATCH 04/16] feat: add help request persistence --- .../repository/HelpRequestEntity.java | 145 ++++++++++++++++++ .../volunteer/repository/HelpRequestRepo.java | 10 ++ .../repository/VolunteerUserStateEntity.java | 38 +++++ .../repository/VolunteerUserStateRepo.java | 6 + 4 files changed, 199 insertions(+) create mode 100644 src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java create mode 100644 src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java 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..cbf8d39 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java @@ -0,0 +1,145 @@ +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 = "requester_latitude") + private Double requesterLatitude; + + @Column(name = "requester_longitude") + private Double requesterLongitude; + + @Column(name = "volunteer_latitude") + private Double volunteerLatitude; + + @Column(name = "volunteer_longitude") + private Double volunteerLongitude; + + @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 Double getRequesterLatitude() { return requesterLatitude; } + public void setRequesterLatitude(Double requesterLatitude) { this.requesterLatitude = requesterLatitude; } + public Double getRequesterLongitude() { return requesterLongitude; } + public void setRequesterLongitude(Double requesterLongitude) { this.requesterLongitude = requesterLongitude; } + public Double getVolunteerLatitude() { return volunteerLatitude; } + public void setVolunteerLatitude(Double volunteerLatitude) { this.volunteerLatitude = volunteerLatitude; } + public Double getVolunteerLongitude() { return volunteerLongitude; } + public void setVolunteerLongitude(Double volunteerLongitude) { this.volunteerLongitude = volunteerLongitude; } + 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..f2300f3 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java @@ -0,0 +1,10 @@ +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); +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java b/src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java new file mode 100644 index 0000000..5c14e09 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java @@ -0,0 +1,38 @@ +package goodroad.volunteer.repository; + +import goodroad.users.repository.UserEntity; +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "volunteer_user_state") +public class VolunteerUserStateEntity { + @Id + @Column(name = "user_id") + private Long userId; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "user_id") + private UserEntity user; + + @Column(name = "volunteer_warnings", nullable = false) + private int volunteerWarnings; + + @Column(name = "requester_warnings", nullable = false) + private int requesterWarnings; + + @Column(name = "volunteer_banned_until") + private Instant volunteerBannedUntil; + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public UserEntity getUser() { return user; } + public void setUser(UserEntity user) { this.user = user; } + public int getVolunteerWarnings() { return volunteerWarnings; } + public void setVolunteerWarnings(int volunteerWarnings) { this.volunteerWarnings = volunteerWarnings; } + public int getRequesterWarnings() { return requesterWarnings; } + public void setRequesterWarnings(int requesterWarnings) { this.requesterWarnings = requesterWarnings; } + public Instant getVolunteerBannedUntil() { return volunteerBannedUntil; } + public void setVolunteerBannedUntil(Instant volunteerBannedUntil) { this.volunteerBannedUntil = volunteerBannedUntil; } +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java b/src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java new file mode 100644 index 0000000..b1f9278 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java @@ -0,0 +1,6 @@ +package goodroad.volunteer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VolunteerUserStateRepo extends JpaRepository { +} From 5b9b2c8b49ddf5bed4ffeb94eb611077ba9d652b Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:19:43 +0300 Subject: [PATCH 05/16] feat: add volunteer moderation persistence --- .../repository/SosNotificationEntity.java | 74 ++++++++++++++++++ .../repository/SosNotificationRepo.java | 9 +++ .../repository/VolunteerComplaintEntity.java | 78 +++++++++++++++++++ .../repository/VolunteerComplaintRepo.java | 8 ++ 4 files changed, 169 insertions(+) create mode 100644 src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java create mode 100644 src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java create mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java diff --git a/src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java b/src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java new file mode 100644 index 0000000..609afa5 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java @@ -0,0 +1,74 @@ +package goodroad.volunteer.repository; + +import goodroad.users.repository.UserEntity; +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "volunteer_sos_notification") +public class SosNotificationEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "request_id", nullable = false) + private HelpRequestEntity request; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "sender_id", nullable = false) + private UserEntity sender; + + @Column(name = "reason", nullable = false, length = 64) + private String reason; + + @Column(name = "comment", length = 1000) + private String comment; + + @Column(name = "status", nullable = false, length = 16) + private String status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moderator_id") + private UserEntity moderator; + + @Column(name = "moderator_comment", length = 1000) + private String moderatorComment; + + @Column(name = "resolved_at") + private Instant resolvedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + if (status == null) { + status = "OPEN"; + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public HelpRequestEntity getRequest() { return request; } + public void setRequest(HelpRequestEntity request) { this.request = request; } + public UserEntity getSender() { return sender; } + public void setSender(UserEntity sender) { this.sender = sender; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + 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 UserEntity getModerator() { return moderator; } + public void setModerator(UserEntity moderator) { this.moderator = moderator; } + public String getModeratorComment() { return moderatorComment; } + public void setModeratorComment(String moderatorComment) { this.moderatorComment = moderatorComment; } + public Instant getResolvedAt() { return resolvedAt; } + public void setResolvedAt(Instant resolvedAt) { this.resolvedAt = resolvedAt; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java b/src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java new file mode 100644 index 0000000..bad9ca6 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java @@ -0,0 +1,9 @@ +package goodroad.volunteer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface SosNotificationRepo extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); + List findByStatusInOrderByCreatedAtDesc(List statuses); +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java b/src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java new file mode 100644 index 0000000..402e968 --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java @@ -0,0 +1,78 @@ +package goodroad.volunteer.repository; + +import goodroad.users.repository.UserEntity; +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "volunteer_complaint") +public class VolunteerComplaintEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "request_id", nullable = false) + private HelpRequestEntity request; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id", nullable = false) + private UserEntity author; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "target_id", nullable = false) + private UserEntity target; + + @Column(name = "text", nullable = false, length = 2000) + private String text; + + @Column(name = "status", nullable = false, length = 16) + private String status = "PENDING"; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guilty_user_id") + private UserEntity guiltyUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "moderator_id") + private UserEntity moderator; + + @Column(name = "moderator_comment", length = 1000) + private String moderatorComment; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "resolved_at") + private Instant resolvedAt; + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public HelpRequestEntity getRequest() { return request; } + public void setRequest(HelpRequestEntity request) { this.request = request; } + public UserEntity getAuthor() { return author; } + public void setAuthor(UserEntity author) { this.author = author; } + public UserEntity getTarget() { return target; } + public void setTarget(UserEntity target) { this.target = target; } + public String getText() { return text; } + public void setText(String text) { this.text = text; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public UserEntity getGuiltyUser() { return guiltyUser; } + public void setGuiltyUser(UserEntity guiltyUser) { this.guiltyUser = guiltyUser; } + public UserEntity getModerator() { return moderator; } + public void setModerator(UserEntity moderator) { this.moderator = moderator; } + public String getModeratorComment() { return moderatorComment; } + public void setModeratorComment(String moderatorComment) { this.moderatorComment = moderatorComment; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getResolvedAt() { return resolvedAt; } + public void setResolvedAt(Instant resolvedAt) { this.resolvedAt = resolvedAt; } +} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java b/src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java new file mode 100644 index 0000000..3b9972a --- /dev/null +++ b/src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java @@ -0,0 +1,8 @@ +package goodroad.volunteer.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface VolunteerComplaintRepo extends JpaRepository { + List findByStatusOrderByCreatedAtAsc(String status); +} From 60eebc60c463c16ebfba1fbc9e512cf1500f263f Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:20:15 +0300 Subject: [PATCH 06/16] feat: implement volunteering service logic --- .../goodroad/volunteer/VolunteerService.java | 873 ++++++++++++++++++ 1 file changed, 873 insertions(+) create mode 100644 src/main/java/goodroad/volunteer/VolunteerService.java diff --git a/src/main/java/goodroad/volunteer/VolunteerService.java b/src/main/java/goodroad/volunteer/VolunteerService.java new file mode 100644 index 0000000..6f019f0 --- /dev/null +++ b/src/main/java/goodroad/volunteer/VolunteerService.java @@ -0,0 +1,873 @@ +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.time.temporal.ChronoUnit; +import java.util.*; + +@Service +public class VolunteerService { + private static final int VOLUNTEER_CANCEL_PENALTY = 50; + 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 VolunteerUserStateRepo states; + private final HelpRequestRepo requests; + private final VolunteerComplaintRepo complaints; + private final SosNotificationRepo sosNotifications; + private final StorageService storageService; + + public VolunteerService( + UserRepo users, + VolunteerApplicationRepo applications, + VolunteerApplicationPhotoRepo applicationPhotos, + VolunteerUserStateRepo states, + HelpRequestRepo requests, + VolunteerComplaintRepo complaints, + SosNotificationRepo sosNotifications, + StorageService storageService + ) { + this.users = users; + this.applications = applications; + this.applicationPhotos = applicationPhotos; + this.states = states; + this.requests = requests; + this.complaints = complaints; + this.sosNotifications = sosNotifications; + this.storageService = storageService; + } + + public record VolunteerMenuResp(boolean volunteer, String applicationStatus, String rejectReason, Instant volunteerBannedUntil) {} + 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 SosReq(String comment) {} + public record LocationReq(Double latitude, Double longitude) {} + public record RoutePointReq(Double latitude, Double longitude) {} + public record WalkRouteReq( + @JsonProperty("points") @JsonAlias("encodedPoints") String encodedPoints, + @JsonProperty("routePoints") List routePoints + ) {} + public record ComplaintReq(String text) {} + public record ComplaintResp(String id, String requestId, String authorId, String targetId, String text, String status, String moderatorComment, Instant createdAt, Instant resolvedAt) {} + public record ResolveComplaintReq(String guiltyUserId, String moderatorComment) {} + public record SosResp(String id, String requestId, String reason, String comment, String status, String moderatorComment, String requesterName, String volunteerName, String requesterPhone, String volunteerPhone, String requesterSocial, String volunteerSocial, Instant createdAt, Instant resolvedAt) {} + public record ResolveSosReq(String moderatorComment) {} + + @Transactional(readOnly = true) + public VolunteerMenuResp getMenu(String phoneFromAuth) { + UserEntity user = findCurrent(phoneFromAuth); + VolunteerUserStateEntity state = states.findById(user.getId()).orElse(null); + VolunteerApplicationEntity last = applications.findFirstByApplicantIdOrderByCreatedAtDesc(user.getId()).orElse(null); + return new VolunteerMenuResp( + isVolunteer(user), + last == null ? null : last.getStatus(), + last == null ? null : last.getModeratorComment(), + state == null ? null : state.getVolunteerBannedUntil() + ); + } + + @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); + getOrCreateState(app.getApplicant()); + 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); + requireNotBanned(volunteer); + 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); + requireNotBanned(volunteer); + 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"); + } + subtractPoints(volunteer, VOLUNTEER_CANCEL_PENALTY); + 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())) { + request.setRequesterStartedAt(now); + } else { + 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 updateLocation(String phoneFromAuth, String id, LocationReq req) { + 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"); + } + if (req == null) { + throw bad("LOCATION_INVALID", "Location is invalid"); + } + validateCoordinates(req.latitude(), req.longitude()); + if (request.getRequester().getId().equals(user.getId())) { + request.setRequesterLatitude(req.latitude()); + request.setRequesterLongitude(req.longitude()); + } else { + request.setVolunteerLatitude(req.latitude()); + request.setVolunteerLongitude(req.longitude()); + } + if (isFarFromPlannedRoute(request, req.latitude(), req.longitude())) { + createSos(request, user, "ROUTE_DEVIATION", "Participant is more than one kilometer away from the planned route"); + } + return toHelpResp(requests.save(request), user, true); + } + + @Transactional + public SosResp sendSos(String phoneFromAuth, String id, SosReq req) { + UserEntity user = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(id); + requireParticipant(request, user); + return createSos(request, user, "MANUAL", req == null ? null : req.comment()); + } + + @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 (Duration.between(request.getStartedAt(), now).toHours() >= 5) { + createSos(request, user, "TIME_LIMIT", "Walk lasts more than five hours"); + } + 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); + } + + @Transactional + public ComplaintResp createComplaint(String phoneFromAuth, String requestId, ComplaintReq req) { + UserEntity author = findCurrent(phoneFromAuth); + HelpRequestEntity request = findRequest(requestId); + requireParticipant(request, author); + String text = InputRules.trimToNull(req == null ? null : req.text()); + if (text == null) { + throw bad("COMPLAINT_TEXT_EMPTY", "Complaint text is empty"); + } + if (request.getVolunteer() == null) { + throw new ApiException(HttpStatus.CONFLICT, "REQUEST_HAS_NO_VOLUNTEER", "Complaint is available after a volunteer accepts the request"); + } + VolunteerComplaintEntity complaint = new VolunteerComplaintEntity(); + complaint.setRequest(request); + complaint.setAuthor(author); + complaint.setTarget(request.getRequester().getId().equals(author.getId()) ? request.getVolunteer() : request.getRequester()); + complaint.setText(text); + return toComplaintResp(complaints.save(complaint)); + } + + @Transactional(readOnly = true) + public List listPendingComplaints() { + return complaints.findByStatusOrderByCreatedAtAsc("PENDING").stream().map(this::toComplaintResp).toList(); + } + + @Transactional + public ComplaintResp resolveComplaint(String moderatorPhone, String id, ResolveComplaintReq req) { + UserEntity moderator = requireModerator(moderatorPhone); + VolunteerComplaintEntity complaint = complaints.findById(parseId(id, "COMPLAINT_ID_INVALID", "Complaint id is invalid")) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "COMPLAINT_NOT_FOUND", "Complaint not found")); + UserEntity guilty = users.findById(parseId(req == null ? null : req.guiltyUserId(), "GUILTY_USER_ID_INVALID", "Guilty user id is invalid")) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "GUILTY_USER_NOT_FOUND", "Guilty user not found")); + applyPenalty(complaint.getRequest(), guilty); + complaint.setStatus("RESOLVED"); + complaint.setGuiltyUser(guilty); + complaint.setModerator(moderator); + complaint.setModeratorComment(InputRules.trimToNull(req.moderatorComment())); + complaint.setResolvedAt(Instant.now()); + return toComplaintResp(complaints.save(complaint)); + } + + @Transactional(readOnly = true) + public List listSosNotifications() { + return sosNotifications.findByStatusInOrderByCreatedAtDesc(List.of("OPEN", "CONFIRMED")).stream().map(this::toSosResp).toList(); + } + + @Transactional(readOnly = true) + public List listAllSosNotifications() { + return sosNotifications.findAllByOrderByCreatedAtDesc().stream().map(this::toSosResp).toList(); + } + + @Transactional + public SosResp confirmSos(String moderatorPhone, String id, ResolveSosReq req) { + return updateSosStatus(moderatorPhone, id, "CONFIRMED", req); + } + + @Transactional + public SosResp markSosFalseAlarm(String moderatorPhone, String id, ResolveSosReq req) { + return updateSosStatus(moderatorPhone, id, "FALSE_ALARM", req); + } + + @Transactional + public SosResp resolveSos(String moderatorPhone, String id, ResolveSosReq req) { + return updateSosStatus(moderatorPhone, id, "RESOLVED", req); + } + + private SosResp updateSosStatus(String moderatorPhone, String id, String status, ResolveSosReq req) { + UserEntity moderator = requireModerator(moderatorPhone); + SosNotificationEntity sos = sosNotifications.findById(parseId(id, "SOS_ID_INVALID", "SOS id is invalid")) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SOS_NOT_FOUND", "SOS notification not found")); + if ("RESOLVED".equals(sos.getStatus()) || "FALSE_ALARM".equals(sos.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "SOS_ALREADY_CLOSED", "SOS notification is already closed"); + } + if ("FALSE_ALARM".equals(status) && "CONFIRMED".equals(sos.getStatus())) { + throw new ApiException(HttpStatus.CONFLICT, "SOS_ALREADY_CONFIRMED", "Confirmed SOS cannot be marked as false alarm"); + } + sos.setStatus(status); + sos.setModerator(moderator); + sos.setModeratorComment(InputRules.trimToNull(req == null ? null : req.moderatorComment())); + if ("RESOLVED".equals(status) || "FALSE_ALARM".equals(status)) { + sos.setResolvedAt(Instant.now()); + } + return toSosResp(sosNotifications.save(sos)); + } + + private void fillRequest(HelpRequestEntity request, HelpRequestReq req) { + request.setFromAddress(InputRules.requireAddressText(req.fromAddress(), "FROM_ADDRESS_INVALID", "From address")); + request.setToAddress(InputRules.requireAddressText(req.toAddress(), "TO_ADDRESS_INVALID", "To address")); + request.setDate(parseDate(req.date())); + request.setTime(parseTime(req.time())); + String phone = Crypto.normPhone(req.phone()); + if (phone.isEmpty()) { + throw bad("PHONE_INVALID", "Phone is invalid"); + } + request.setPhone(phone); + request.setSocialNickname(InputRules.trimToNull(req.socialNickname())); + String comment = InputRules.trimToNull(req.comment()); + if (comment == null) { + throw bad("HELP_COMMENT_EMPTY", "Help comment is empty"); + } + request.setComment(comment); + } + + private VolunteerApplicationResp toApplicationResp(VolunteerApplicationEntity app) { + return new VolunteerApplicationResp( + 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 allowOwnContacts) { + boolean participant = isParticipant(request, viewer); + boolean contactsVisible = allowOwnContacts && participant && request.getVolunteer() != null; + boolean canStart = participant && "ACCEPTED".equals(request.getStatus()) && scheduledTime(request).minus(30, ChronoUnit.MINUTES).isBefore(Instant.now()); + return new HelpRequestResp( + request.getId().toString(), + request.getRequester().getId().toString(), + request.getVolunteer() == null ? null : request.getVolunteer().getId().toString(), + request.getFromAddress(), + request.getToAddress(), + request.getDate().format(DATE_FORMAT), + request.getTime().toString(), + contactsVisible ? request.getPhone() : null, + contactsVisible ? request.getSocialNickname() : null, + request.getComment(), + request.getStatus(), + contactsVisible, + canStart, + request.getStartedAt() != null, + request.getCompletedAt() != null, + request.getCreatedAt() + ); + } + + private ComplaintResp toComplaintResp(VolunteerComplaintEntity complaint) { + return new ComplaintResp( + complaint.getId().toString(), + complaint.getRequest().getId().toString(), + complaint.getAuthor().getId().toString(), + complaint.getTarget().getId().toString(), + complaint.getText(), + complaint.getStatus(), + complaint.getModeratorComment(), + complaint.getCreatedAt(), + complaint.getResolvedAt() + ); + } + + + private void saveWalkRoute(HelpRequestEntity request, WalkRouteReq req) { + List points = extractRoutePoints(req); + if (points.size() < 2) { + throw bad("ROUTE_POINTS_INVALID", "Route must contain at least two valid 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 extractRoutePoints(WalkRouteReq req) { + if (req == null) { + throw bad("ROUTE_EMPTY", "Route is empty"); + } + if (req.routePoints() != null && !req.routePoints().isEmpty()) { + return req.routePoints(); + } + String encoded = InputRules.trimToNull(req.encodedPoints()); + if (encoded != null) { + return decodePolyline(encoded); + } + throw bad("ROUTE_EMPTY", "Route is empty"); + } + + private List decodePolyline(String encoded) { + List points = new ArrayList<>(); + int index = 0; + int lat = 0; + int lon = 0; + while (index < encoded.length()) { + int[] latResult = decodePolylineValue(encoded, index); + lat += latResult[0]; + index = latResult[1]; + if (index >= encoded.length()) { + throw bad("ROUTE_POINTS_INVALID", "Encoded route is invalid"); + } + int[] lonResult = decodePolylineValue(encoded, index); + lon += lonResult[0]; + index = lonResult[1]; + points.add(new RoutePointReq(lat / 100000.0, lon / 100000.0)); + } + 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 boolean isFarFromPlannedRoute(HelpRequestEntity request, double latitude, double longitude) { + List route = parseStoredRoute(request.getPlannedRoutePoints()); + if (route.size() < 2) { + return false; + } + double minDistance = Double.MAX_VALUE; + for (int i = 1; i < route.size(); i++) { + RoutePointReq a = route.get(i - 1); + RoutePointReq b = route.get(i); + minDistance = Math.min(minDistance, distanceToSegmentMeters(latitude, longitude, a.latitude(), a.longitude(), b.latitude(), b.longitude())); + } + return minDistance > 1000; + } + + private List parseStoredRoute(String raw) { + String value = InputRules.trimToNull(raw); + if (value == null) { + return List.of(); + } + List points = new ArrayList<>(); + for (String part : value.split(";")) { + String[] coords = part.split(","); + if (coords.length != 2) { + return List.of(); + } + try { + points.add(new RoutePointReq(Double.parseDouble(coords[0]), Double.parseDouble(coords[1]))); + } catch (NumberFormatException e) { + return List.of(); + } + } + return points; + } + + private double distanceToSegmentMeters(double pointLat, double pointLon, double startLat, double startLon, double endLat, double endLon) { + double refLat = Math.toRadians(pointLat); + double px = lonToMeters(pointLon, refLat); + double py = latToMeters(pointLat); + double ax = lonToMeters(startLon, refLat); + double ay = latToMeters(startLat); + double bx = lonToMeters(endLon, refLat); + double by = latToMeters(endLat); + double dx = bx - ax; + double dy = by - ay; + if (dx == 0 && dy == 0) { + return Math.hypot(px - ax, py - ay); + } + double t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy); + t = Math.max(0, Math.min(1, t)); + double closestX = ax + t * dx; + double closestY = ay + t * dy; + return Math.hypot(px - closestX, py - closestY); + } + + private double latToMeters(double lat) { + return Math.toRadians(lat) * 6371000.0; + } + + private double lonToMeters(double lon, double refLat) { + return Math.toRadians(lon) * 6371000.0 * Math.cos(refLat); + } + + 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 SosResp createSos(HelpRequestEntity request, UserEntity sender, String reason, String comment) { + SosNotificationEntity sos = new SosNotificationEntity(); + sos.setRequest(request); + sos.setSender(sender); + sos.setReason(reason); + sos.setComment(InputRules.trimToNull(comment)); + sos.setStatus("OPEN"); + return toSosResp(sosNotifications.save(sos)); + } + + private SosResp toSosResp(SosNotificationEntity sos) { + HelpRequestEntity request = sos.getRequest(); + return new SosResp( + sos.getId().toString(), + request.getId().toString(), + sos.getReason(), + sos.getComment(), + sos.getStatus(), + sos.getModeratorComment(), + joinName(request.getRequester()), + request.getVolunteer() == null ? null : joinName(request.getVolunteer()), + request.getPhone(), + volunteerContact(request.getVolunteer(), true), + request.getSocialNickname(), + volunteerContact(request.getVolunteer(), false), + sos.getCreatedAt(), + sos.getResolvedAt() + ); + } + + private String volunteerContact(UserEntity volunteer, boolean phone) { + if (volunteer == null) { + return null; + } + return applications.findFirstByApplicantIdOrderByCreatedAtDesc(volunteer.getId()) + .filter(app -> "APPROVED".equals(app.getStatus())) + .map(app -> phone ? app.getPhone() : app.getSocialNickname()) + .orElse(null); + } + + private double distanceMeters(double lat1, double lon1, double lat2, double lon2) { + double radius = 6371000.0; + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + return 2 * radius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + private void applyPenalty(HelpRequestEntity request, UserEntity guilty) { + VolunteerUserStateEntity state = getOrCreateState(guilty); + boolean guiltyVolunteer = request.getVolunteer() != null && request.getVolunteer().getId().equals(guilty.getId()); + if (guiltyVolunteer) { + int warnings = state.getVolunteerWarnings() + 1; + state.setVolunteerWarnings(warnings); + if (warnings == 1) { + subtractPoints(guilty, 50); + } else if (warnings == 2) { + subtractPoints(guilty, 75); + } else { + subtractPoints(guilty, 100); + state.setVolunteerWarnings(0); + state.setVolunteerBannedUntil(Instant.now().plus(Duration.ofDays(7))); + } + } else { + int warnings = state.getRequesterWarnings() + 1; + state.setRequesterWarnings(warnings); + if (warnings == 1) { + subtractPoints(guilty, 25); + } else if (warnings == 2) { + subtractPoints(guilty, 50); + } else { + subtractPoints(guilty, 75); + state.setRequesterWarnings(0); + } + } + states.save(state); + } + + private VolunteerUserStateEntity getOrCreateState(UserEntity user) { + return states.findById(user.getId()).orElseGet(() -> { + VolunteerUserStateEntity state = new VolunteerUserStateEntity(); + state.setUser(user); + return states.save(state); + }); + } + + private void subtractPoints(UserEntity user, int points) { + user.setTotalPoints(Math.max(0, user.getTotalPoints() - points)); + users.save(user); + } + + 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 void requireNotBanned(UserEntity volunteer) { + VolunteerUserStateEntity state = states.findById(volunteer.getId()).orElse(null); + if (state != null && state.getVolunteerBannedUntil() != null && state.getVolunteerBannedUntil().isAfter(Instant.now())) { + throw new ApiException(HttpStatus.FORBIDDEN, "VOLUNTEER_BANNED", "Volunteer is temporarily banned"); + } + } + + 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(); + } +} From 032579714377871f27bd24a4c91e7bcb9f3216d9 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:20:44 +0300 Subject: [PATCH 07/16] feat: add volunteering REST endpoints --- .../volunteer/VolunteerController.java | 139 ++++++++++++++++++ .../VolunteerModerationController.java | 85 +++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/main/java/goodroad/volunteer/VolunteerController.java create mode 100644 src/main/java/goodroad/volunteer/VolunteerModerationController.java diff --git a/src/main/java/goodroad/volunteer/VolunteerController.java b/src/main/java/goodroad/volunteer/VolunteerController.java new file mode 100644 index 0000000..5117a9f --- /dev/null +++ b/src/main/java/goodroad/volunteer/VolunteerController.java @@ -0,0 +1,139 @@ +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); + } + + @PostMapping("/requests/{id}/location") + public VolunteerService.HelpRequestResp updateLocation( + Authentication authentication, + @PathVariable String id, + @RequestBody VolunteerService.LocationReq req + ) { + return service.updateLocation(authentication.getName(), id, req); + } + + @PostMapping("/requests/{id}/sos") + public VolunteerService.SosResp sendSos( + Authentication authentication, + @PathVariable String id, + @RequestBody(required = false) VolunteerService.SosReq req + ) { + return service.sendSos(authentication.getName(), id, req); + } + + @PostMapping("/requests/{id}/complaints") + public VolunteerService.ComplaintResp createComplaint( + Authentication authentication, + @PathVariable String id, + @RequestBody VolunteerService.ComplaintReq req + ) { + return service.createComplaint(authentication.getName(), id, req); + } +} diff --git a/src/main/java/goodroad/volunteer/VolunteerModerationController.java b/src/main/java/goodroad/volunteer/VolunteerModerationController.java new file mode 100644 index 0000000..129193a --- /dev/null +++ b/src/main/java/goodroad/volunteer/VolunteerModerationController.java @@ -0,0 +1,85 @@ +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); + } + + @GetMapping("/complaints/pending") + public List listPendingComplaints() { + return service.listPendingComplaints(); + } + + @PostMapping("/complaints/{id}/resolve") + public VolunteerService.ComplaintResp resolveComplaint( + Authentication authentication, + @PathVariable String id, + @RequestBody VolunteerService.ResolveComplaintReq req + ) { + return service.resolveComplaint(authentication.getName(), id, req); + } + + @GetMapping("/sos") + public List listSosNotifications() { + return service.listSosNotifications(); + } + + @GetMapping("/sos/all") + public List listAllSosNotifications() { + return service.listAllSosNotifications(); + } + + @PostMapping("/sos/{id}/confirm") + public VolunteerService.SosResp confirmSos( + Authentication authentication, + @PathVariable String id, + @RequestBody(required = false) VolunteerService.ResolveSosReq req + ) { + return service.confirmSos(authentication.getName(), id, req); + } + + @PostMapping("/sos/{id}/false-alarm") + public VolunteerService.SosResp markSosFalseAlarm( + Authentication authentication, + @PathVariable String id, + @RequestBody(required = false) VolunteerService.ResolveSosReq req + ) { + return service.markSosFalseAlarm(authentication.getName(), id, req); + } + + @PostMapping("/sos/{id}/resolve") + public VolunteerService.SosResp resolveSos( + Authentication authentication, + @PathVariable String id, + @RequestBody(required = false) VolunteerService.ResolveSosReq req + ) { + return service.resolveSos(authentication.getName(), id, req); + } +} + From 53f649672b60bce1102005425ad53fd85aa9bf8c Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:21:09 +0300 Subject: [PATCH 08/16] test: add volunteering service tests --- .../volunteer/VolunteerServiceTest.java | 498 ++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 src/test/java/goodroad/volunteer/VolunteerServiceTest.java diff --git a/src/test/java/goodroad/volunteer/VolunteerServiceTest.java b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java new file mode 100644 index 0000000..3b00c53 --- /dev/null +++ b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java @@ -0,0 +1,498 @@ +package goodroad.volunteer; + +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 VolunteerUserStateRepo states; + + @Mock + private HelpRequestRepo requests; + + @Mock + private VolunteerComplaintRepo complaints; + + @Mock + private SosNotificationRepo sosNotifications; + + @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"); + VolunteerUserStateEntity state = state(applicant); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(moderator)); + when(applications.findById(20L)).thenReturn(Optional.of(application)); + when(applications.save(application)).thenReturn(application); + when(states.findById(1L)).thenReturn(Optional.empty()); + when(states.save(any(VolunteerUserStateEntity.class))).thenReturn(state); + + 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)).thenAnswer(invocation -> 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(states.findById(2L)).thenReturn(Optional.empty()); + 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 shouldWithdrawResponseAndSubtractFiftyPoints() { + 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(30, volunteer.getTotalPoints()); + verify(users).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.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 shouldCreateSosWhenParticipantIsMoreThanOneKilometerAwayFromRoute() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + request.setStartedAt(Instant.now()); + request.setPlannedRoutePoints("59.93,30.31;59.93,30.32"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.save(request)).thenReturn(request); + when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); + when(sosNotifications.save(any(SosNotificationEntity.class))).thenAnswer(invocation -> { + SosNotificationEntity sos = invocation.getArgument(0); + sos.setId(100L); + sos.setCreatedAt(Instant.now()); + return sos; + }); + + service.updateLocation("+79990000002", "30", new VolunteerService.LocationReq(59.95, 30.31)); + + verify(sosNotifications).save(argThat(sos -> "ROUTE_DEVIATION".equals(sos.getReason()))); + } + + @Test + void shouldNotCreateSosWhenParticipantIsCloseToRoute() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + request.setStartedAt(Instant.now()); + request.setPlannedRoutePoints("59.93,30.31;59.93,30.32"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(requests.findById(30L)).thenReturn(Optional.of(request)); + when(requests.save(request)).thenReturn(request); + + service.updateLocation("+79990000002", "30", new VolunteerService.LocationReq(59.9305, 30.315)); + + verifyNoInteractions(sosNotifications); + } + + @Test + void shouldFinishWalkAndAddRewardAfterBothParticipantsFinish() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + volunteer.setTotalPoints(20); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + request.setStartedAt(Instant.now()); + 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(120, volunteer.getTotalPoints()); + verify(users).save(volunteer); + } + + @Test + void shouldApplyVolunteerComplaintPenaltyAndWeekBanOnThirdWarning() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); + volunteer.setTotalPoints(200); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + VolunteerComplaintEntity complaint = complaint(40L, request, requester, volunteer); + VolunteerUserStateEntity state = state(volunteer); + state.setVolunteerWarnings(2); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); + when(complaints.findById(40L)).thenReturn(Optional.of(complaint)); + when(users.findById(2L)).thenReturn(Optional.of(volunteer)); + when(states.findById(2L)).thenReturn(Optional.of(state)); + when(states.save(state)).thenReturn(state); + when(complaints.save(complaint)).thenReturn(complaint); + + VolunteerService.ComplaintResp result = service.resolveComplaint( + "+79990000003", + "40", + new VolunteerService.ResolveComplaintReq("2", "Волонтер не пришел") + ); + + assertEquals("RESOLVED", result.status()); + assertEquals(100, volunteer.getTotalPoints()); + assertEquals(0, state.getVolunteerWarnings()); + assertNotNull(state.getVolunteerBannedUntil()); + assertTrue(state.getVolunteerBannedUntil().isAfter(Instant.now().plus(Duration.ofDays(6)))); + } + + @Test + void shouldRejectAvailableRequestsForBannedVolunteer() { + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + VolunteerUserStateEntity state = state(volunteer); + state.setVolunteerBannedUntil(Instant.now().plus(Duration.ofDays(1))); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); + when(states.findById(2L)).thenReturn(Optional.of(state)); + + assertThrows(RuntimeException.class, () -> service.listAvailableRequests("+79990000002", 59.93, 30.31)); + } + + + @Test + void shouldConfirmSosAndKeepItOpenUntilResolved() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + SosNotificationEntity sos = sos(100L, request, requester, "MANUAL", "OPEN"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); + when(sosNotifications.findById(100L)).thenReturn(Optional.of(sos)); + when(sosNotifications.save(sos)).thenReturn(sos); + when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); + + VolunteerService.SosResp result = service.confirmSos( + "+79990000003", + "100", + new VolunteerService.ResolveSosReq("Связались с участниками") + ); + + assertEquals("CONFIRMED", result.status()); + assertEquals("CONFIRMED", sos.getStatus()); + assertEquals(moderator, sos.getModerator()); + assertEquals("Связались с участниками", sos.getModeratorComment()); + assertNull(sos.getResolvedAt()); + } + + @Test + void shouldMarkSosAsFalseAlarmAndCloseIt() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + SosNotificationEntity sos = sos(100L, request, requester, "MANUAL", "OPEN"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); + when(sosNotifications.findById(100L)).thenReturn(Optional.of(sos)); + when(sosNotifications.save(sos)).thenReturn(sos); + when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); + + VolunteerService.SosResp result = service.markSosFalseAlarm( + "+79990000003", + "100", + new VolunteerService.ResolveSosReq("Ошибочное нажатие") + ); + + assertEquals("FALSE_ALARM", result.status()); + assertEquals("FALSE_ALARM", sos.getStatus()); + assertNotNull(sos.getResolvedAt()); + } + + @Test + void shouldResolveConfirmedSos() { + UserEntity requester = user(1L, "USER", "+79990000001"); + UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); + UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); + HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); + SosNotificationEntity sos = sos(100L, request, volunteer, "ROUTE_DEVIATION", "CONFIRMED"); + when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); + when(sosNotifications.findById(100L)).thenReturn(Optional.of(sos)); + when(sosNotifications.save(sos)).thenReturn(sos); + when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); + + VolunteerService.SosResp result = service.resolveSos( + "+79990000003", + "100", + new VolunteerService.ResolveSosReq("Участники в безопасности") + ); + + assertEquals("RESOLVED", result.status()); + assertEquals("RESOLVED", sos.getStatus()); + assertNotNull(sos.getResolvedAt()); + } + + @Test + void shouldListOnlyOpenAndConfirmedSosInActiveModerationList() { + when(sosNotifications.findByStatusInOrderByCreatedAtDesc(List.of("OPEN", "CONFIRMED"))).thenReturn(List.of()); + + service.listSosNotifications(); + + verify(sosNotifications).findByStatusInOrderByCreatedAtDesc(List.of("OPEN", "CONFIRMED")); + verify(sosNotifications, never()).findAllByOrderByCreatedAtDesc(); + } + + private VolunteerService.HelpRequestReq helpRequestReq() { + return new VolunteerService.HelpRequestReq( + "Санкт-Петербург, Садовая улица, дом 12", + "Санкт-Петербург, Невский проспект, дом 1", + LocalDate.now().plusDays(1).format(DATE_FORMAT), + "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("Имя" + id) + .lastName("Фамилия" + id) + .phoneHash(Crypto.sha256Hex(Crypto.normPhone(phone))) + .role(role) + .passHash("hash") + .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.now()); + return application; + } + + private VolunteerUserStateEntity state(UserEntity user) { + VolunteerUserStateEntity state = new VolunteerUserStateEntity(); + state.setUser(user); + state.setUserId(user.getId()); + return state; + } + + 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.now()); + return request; + } + + + private SosNotificationEntity sos(Long id, HelpRequestEntity request, UserEntity sender, String reason, String status) { + SosNotificationEntity sos = new SosNotificationEntity(); + sos.setId(id); + sos.setRequest(request); + sos.setSender(sender); + sos.setReason(reason); + sos.setStatus(status); + sos.setComment("Комментарий SOS"); + sos.setCreatedAt(Instant.now()); + return sos; + } + + private VolunteerComplaintEntity complaint(Long id, HelpRequestEntity request, UserEntity author, UserEntity target) { + VolunteerComplaintEntity complaint = new VolunteerComplaintEntity(); + complaint.setId(id); + complaint.setRequest(request); + complaint.setAuthor(author); + complaint.setTarget(target); + complaint.setText("Жалоба"); + complaint.setStatus("PENDING"); + complaint.setCreatedAt(Instant.now()); + return complaint; + } +} From ca95f9bfccd886d8c147582505a84be2eedb58c3 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:21:34 +0300 Subject: [PATCH 09/16] test: add volunteering controller tests --- .../volunteer/VolunteerControllerTest.java | 309 ++++++++++++++++++ .../VolunteerModerationControllerTest.java | 199 +++++++++++ 2 files changed, 508 insertions(+) create mode 100644 src/test/java/goodroad/volunteer/VolunteerControllerTest.java create mode 100644 src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java diff --git a/src/test/java/goodroad/volunteer/VolunteerControllerTest.java b/src/test/java/goodroad/volunteer/VolunteerControllerTest.java new file mode 100644 index 0000000..33a4a91 --- /dev/null +++ b/src/test/java/goodroad/volunteer/VolunteerControllerTest.java @@ -0,0 +1,309 @@ +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, 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 shouldUseWalkAndSosEndpoints() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); + Principal user = principal("+79990000001"); + VolunteerService.HelpRequestResp active = helpRequest("20", "2", "IN_PROGRESS", true, true, false); + VolunteerService.HelpRequestResp completed = helpRequest("20", "2", "COMPLETED", true, true, true); + VolunteerService.SosResp sos = sos("30", "20", "MANUAL", "OPEN"); + VolunteerService.ComplaintResp complaint = complaint("40", "20", "PENDING"); + + 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.updateLocation(eq("+79990000001"), eq("20"), any(VolunteerService.LocationReq.class))).thenReturn(active); + when(service.finishWalk("+79990000001", "20")).thenReturn(completed); + when(service.sendSos(eq("+79990000001"), eq("20"), any(VolunteerService.SosReq.class))).thenReturn(sos); + when(service.createComplaint(eq("+79990000001"), eq("20"), any(VolunteerService.ComplaintReq.class))).thenReturn(complaint); + + 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("IN_PROGRESS")); + + mvc.perform(post("/volunteer/requests/20/location") + .principal(user) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "latitude": 59.93, + "longitude": 30.31 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("20")); + + mvc.perform(post("/volunteer/requests/20/sos") + .principal(user) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "comment": "Нужна помощь" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("OPEN")); + + mvc.perform(post("/volunteer/requests/20/complaints") + .principal(user) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "text": "Волонтер не пришел" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PENDING")); + + mvc.perform(post("/volunteer/requests/20/finish").principal(user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.completed").value(true)); + + verify(service).updateLocation(eq("+79990000001"), eq("20"), argThat(req -> + Double.valueOf(59.93).equals(req.latitude()) && Double.valueOf(30.31).equals(req.longitude()) + )); + verify(service).sendSos(eq("+79990000001"), eq("20"), argThat(req -> + "Нужна помощь".equals(req.comment()) + )); + } + + 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 VolunteerService.SosResp sos(String id, String requestId, String reason, String status) { + return new VolunteerService.SosResp( + id, requestId, reason, "Нужна помощь", status, null, + "Иван Петров", "Анна Иванова", "79990000001", "79990000002", "@user", "@volunteer", + Instant.parse("2026-05-01T10:00:00Z"), null + ); + } + + private VolunteerService.ComplaintResp complaint(String id, String requestId, String status) { + return new VolunteerService.ComplaintResp( + id, requestId, "1", "2", "Волонтер не пришел", status, null, + Instant.parse("2026-05-01T10:00:00Z"), null + ); + } + + 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..4799976 --- /dev/null +++ b/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java @@ -0,0 +1,199 @@ +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()) + )); + } + + @Test + void shouldModerateComplaints() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerModerationController(service)).build(); + Principal moderator = principal("+79990000151"); + VolunteerService.ComplaintResp pending = complaint("40", "PENDING", null); + VolunteerService.ComplaintResp resolved = complaint("40", "RESOLVED", "Виновный предупрежден"); + + when(service.listPendingComplaints()).thenReturn(List.of(pending)); + when(service.resolveComplaint(eq("+79990000151"), eq("40"), any(VolunteerService.ResolveComplaintReq.class))) + .thenReturn(resolved); + + mvc.perform(get("/volunteer/moderation/complaints/pending").principal(moderator)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("40")) + .andExpect(jsonPath("$[0].status").value("PENDING")); + + mvc.perform(post("/volunteer/moderation/complaints/40/resolve") + .principal(moderator) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "guiltyUserId": "2", + "moderatorComment": "Виновный предупрежден" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("RESOLVED")) + .andExpect(jsonPath("$.moderatorComment").value("Виновный предупрежден")); + + verify(service).resolveComplaint(eq("+79990000151"), eq("40"), argThat(req -> + "2".equals(req.guiltyUserId()) && "Виновный предупрежден".equals(req.moderatorComment()) + )); + } + + @Test + void shouldModerateSosNotifications() throws Exception { + MockMvc mvc = standaloneSetup(new VolunteerModerationController(service)).build(); + Principal moderator = principal("+79990000151"); + VolunteerService.SosResp open = sos("30", "OPEN", null); + VolunteerService.SosResp confirmed = sos("30", "CONFIRMED", "Связались с участниками"); + VolunteerService.SosResp falseAlarm = sos("31", "FALSE_ALARM", "Ошибочное нажатие"); + VolunteerService.SosResp resolved = sos("30", "RESOLVED", "Ситуация решена"); + + when(service.listSosNotifications()).thenReturn(List.of(open, confirmed)); + when(service.listAllSosNotifications()).thenReturn(List.of(open, confirmed, falseAlarm, resolved)); + when(service.confirmSos(eq("+79990000151"), eq("30"), any(VolunteerService.ResolveSosReq.class))) + .thenReturn(confirmed); + when(service.markSosFalseAlarm(eq("+79990000151"), eq("31"), any(VolunteerService.ResolveSosReq.class))) + .thenReturn(falseAlarm); + when(service.resolveSos(eq("+79990000151"), eq("30"), any(VolunteerService.ResolveSosReq.class))) + .thenReturn(resolved); + + mvc.perform(get("/volunteer/moderation/sos").principal(moderator)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].status").value("OPEN")) + .andExpect(jsonPath("$[1].status").value("CONFIRMED")); + + mvc.perform(get("/volunteer/moderation/sos/all").principal(moderator)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[2].status").value("FALSE_ALARM")) + .andExpect(jsonPath("$[3].status").value("RESOLVED")); + + mvc.perform(post("/volunteer/moderation/sos/30/confirm") + .principal(moderator) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "moderatorComment": "Связались с участниками" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CONFIRMED")); + + mvc.perform(post("/volunteer/moderation/sos/31/false-alarm") + .principal(moderator) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "moderatorComment": "Ошибочное нажатие" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("FALSE_ALARM")); + + mvc.perform(post("/volunteer/moderation/sos/30/resolve") + .principal(moderator) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "moderatorComment": "Ситуация решена" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("RESOLVED")); + + verify(service).confirmSos(eq("+79990000151"), eq("30"), argThat(req -> + "Связались с участниками".equals(req.moderatorComment()) + )); + verify(service).markSosFalseAlarm(eq("+79990000151"), eq("31"), argThat(req -> + "Ошибочное нажатие".equals(req.moderatorComment()) + )); + verify(service).resolveSos(eq("+79990000151"), eq("30"), argThat(req -> + "Ситуация решена".equals(req.moderatorComment()) + )); + } + + 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.ComplaintResp complaint(String id, String status, String comment) { + return new VolunteerService.ComplaintResp( + id, "20", "1", "2", "Волонтер не пришел", status, comment, + Instant.parse("2026-05-01T10:00:00Z"), "RESOLVED".equals(status) ? Instant.parse("2026-05-01T11:00:00Z") : null + ); + } + + private VolunteerService.SosResp sos(String id, String status, String comment) { + return new VolunteerService.SosResp( + id, "20", "MANUAL", "Нужна помощь", status, comment, + "Иван Петров", "Анна Иванова", "79990000001", "79990000002", "@user", "@volunteer", + Instant.parse("2026-05-01T10:00:00Z"), + List.of("RESOLVED", "FALSE_ALARM").contains(status) ? Instant.parse("2026-05-01T11:00:00Z") : null + ); + } +} From c761cd00e5ab5990a0f786a87e498c220ba29f81 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 03:22:01 +0300 Subject: [PATCH 10/16] test: update user review service test setup --- .../reviews/UserReviewServiceTest.java | 116 ++++++++++++++---- 1 file changed, 92 insertions(+), 24 deletions(-) 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 From 9b9041a8d7221d338f4a42029d9bb927d3905215 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 15:52:49 +0300 Subject: [PATCH 11/16] refactor: remove volunteering sos and complaints persistence --- .../repository/HelpRequestEntity.java | 19 ----- .../volunteer/repository/HelpRequestRepo.java | 2 + .../repository/SosNotificationEntity.java | 74 ------------------ .../repository/SosNotificationRepo.java | 9 --- .../repository/VolunteerComplaintEntity.java | 78 ------------------- .../repository/VolunteerComplaintRepo.java | 8 -- .../repository/VolunteerUserStateEntity.java | 38 --------- .../repository/VolunteerUserStateRepo.java | 6 -- .../db/migration/V2__volunteer_schema.sql | 47 ----------- 9 files changed, 2 insertions(+), 279 deletions(-) delete mode 100644 src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java delete mode 100644 src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java delete mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java delete mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java delete mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java delete mode 100644 src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java diff --git a/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java b/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java index cbf8d39..72dfa77 100644 --- a/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestEntity.java @@ -70,17 +70,6 @@ public class HelpRequestEntity { @Column(name = "completed_at") private Instant completedAt; - @Column(name = "requester_latitude") - private Double requesterLatitude; - - @Column(name = "requester_longitude") - private Double requesterLongitude; - - @Column(name = "volunteer_latitude") - private Double volunteerLatitude; - - @Column(name = "volunteer_longitude") - private Double volunteerLongitude; @Column(name = "planned_route_points", columnDefinition = "text") private String plannedRoutePoints; @@ -132,14 +121,6 @@ private void prePersist() { public void setVolunteerFinishedAt(Instant volunteerFinishedAt) { this.volunteerFinishedAt = volunteerFinishedAt; } public Instant getCompletedAt() { return completedAt; } public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } - public Double getRequesterLatitude() { return requesterLatitude; } - public void setRequesterLatitude(Double requesterLatitude) { this.requesterLatitude = requesterLatitude; } - public Double getRequesterLongitude() { return requesterLongitude; } - public void setRequesterLongitude(Double requesterLongitude) { this.requesterLongitude = requesterLongitude; } - public Double getVolunteerLatitude() { return volunteerLatitude; } - public void setVolunteerLatitude(Double volunteerLatitude) { this.volunteerLatitude = volunteerLatitude; } - public Double getVolunteerLongitude() { return volunteerLongitude; } - public void setVolunteerLongitude(Double volunteerLongitude) { this.volunteerLongitude = volunteerLongitude; } 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 index f2300f3..37ff615 100644 --- a/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java +++ b/src/main/java/goodroad/volunteer/repository/HelpRequestRepo.java @@ -7,4 +7,6 @@ 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/SosNotificationEntity.java b/src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java deleted file mode 100644 index 609afa5..0000000 --- a/src/main/java/goodroad/volunteer/repository/SosNotificationEntity.java +++ /dev/null @@ -1,74 +0,0 @@ -package goodroad.volunteer.repository; - -import goodroad.users.repository.UserEntity; -import jakarta.persistence.*; -import java.time.Instant; - -@Entity -@Table(name = "volunteer_sos_notification") -public class SosNotificationEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "request_id", nullable = false) - private HelpRequestEntity request; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "sender_id", nullable = false) - private UserEntity sender; - - @Column(name = "reason", nullable = false, length = 64) - private String reason; - - @Column(name = "comment", length = 1000) - private String comment; - - @Column(name = "status", nullable = false, length = 16) - private String status; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "moderator_id") - private UserEntity moderator; - - @Column(name = "moderator_comment", length = 1000) - private String moderatorComment; - - @Column(name = "resolved_at") - private Instant resolvedAt; - - @Column(name = "created_at", nullable = false) - private Instant createdAt; - - @PrePersist - private void prePersist() { - if (createdAt == null) { - createdAt = Instant.now(); - } - if (status == null) { - status = "OPEN"; - } - } - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - public HelpRequestEntity getRequest() { return request; } - public void setRequest(HelpRequestEntity request) { this.request = request; } - public UserEntity getSender() { return sender; } - public void setSender(UserEntity sender) { this.sender = sender; } - public String getReason() { return reason; } - public void setReason(String reason) { this.reason = reason; } - 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 UserEntity getModerator() { return moderator; } - public void setModerator(UserEntity moderator) { this.moderator = moderator; } - public String getModeratorComment() { return moderatorComment; } - public void setModeratorComment(String moderatorComment) { this.moderatorComment = moderatorComment; } - public Instant getResolvedAt() { return resolvedAt; } - public void setResolvedAt(Instant resolvedAt) { this.resolvedAt = resolvedAt; } - public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } -} diff --git a/src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java b/src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java deleted file mode 100644 index bad9ca6..0000000 --- a/src/main/java/goodroad/volunteer/repository/SosNotificationRepo.java +++ /dev/null @@ -1,9 +0,0 @@ -package goodroad.volunteer.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - -public interface SosNotificationRepo extends JpaRepository { - List findAllByOrderByCreatedAtDesc(); - List findByStatusInOrderByCreatedAtDesc(List statuses); -} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java b/src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java deleted file mode 100644 index 402e968..0000000 --- a/src/main/java/goodroad/volunteer/repository/VolunteerComplaintEntity.java +++ /dev/null @@ -1,78 +0,0 @@ -package goodroad.volunteer.repository; - -import goodroad.users.repository.UserEntity; -import jakarta.persistence.*; -import java.time.Instant; - -@Entity -@Table(name = "volunteer_complaint") -public class VolunteerComplaintEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "request_id", nullable = false) - private HelpRequestEntity request; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "author_id", nullable = false) - private UserEntity author; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "target_id", nullable = false) - private UserEntity target; - - @Column(name = "text", nullable = false, length = 2000) - private String text; - - @Column(name = "status", nullable = false, length = 16) - private String status = "PENDING"; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "guilty_user_id") - private UserEntity guiltyUser; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "moderator_id") - private UserEntity moderator; - - @Column(name = "moderator_comment", length = 1000) - private String moderatorComment; - - @Column(name = "created_at", nullable = false) - private Instant createdAt; - - @Column(name = "resolved_at") - private Instant resolvedAt; - - @PrePersist - private void prePersist() { - if (createdAt == null) { - createdAt = Instant.now(); - } - } - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - public HelpRequestEntity getRequest() { return request; } - public void setRequest(HelpRequestEntity request) { this.request = request; } - public UserEntity getAuthor() { return author; } - public void setAuthor(UserEntity author) { this.author = author; } - public UserEntity getTarget() { return target; } - public void setTarget(UserEntity target) { this.target = target; } - public String getText() { return text; } - public void setText(String text) { this.text = text; } - public String getStatus() { return status; } - public void setStatus(String status) { this.status = status; } - public UserEntity getGuiltyUser() { return guiltyUser; } - public void setGuiltyUser(UserEntity guiltyUser) { this.guiltyUser = guiltyUser; } - public UserEntity getModerator() { return moderator; } - public void setModerator(UserEntity moderator) { this.moderator = moderator; } - public String getModeratorComment() { return moderatorComment; } - public void setModeratorComment(String moderatorComment) { this.moderatorComment = moderatorComment; } - public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - public Instant getResolvedAt() { return resolvedAt; } - public void setResolvedAt(Instant resolvedAt) { this.resolvedAt = resolvedAt; } -} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java b/src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java deleted file mode 100644 index 3b9972a..0000000 --- a/src/main/java/goodroad/volunteer/repository/VolunteerComplaintRepo.java +++ /dev/null @@ -1,8 +0,0 @@ -package goodroad.volunteer.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - -public interface VolunteerComplaintRepo extends JpaRepository { - List findByStatusOrderByCreatedAtAsc(String status); -} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java b/src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java deleted file mode 100644 index 5c14e09..0000000 --- a/src/main/java/goodroad/volunteer/repository/VolunteerUserStateEntity.java +++ /dev/null @@ -1,38 +0,0 @@ -package goodroad.volunteer.repository; - -import goodroad.users.repository.UserEntity; -import jakarta.persistence.*; -import java.time.Instant; - -@Entity -@Table(name = "volunteer_user_state") -public class VolunteerUserStateEntity { - @Id - @Column(name = "user_id") - private Long userId; - - @OneToOne(fetch = FetchType.LAZY, optional = false) - @MapsId - @JoinColumn(name = "user_id") - private UserEntity user; - - @Column(name = "volunteer_warnings", nullable = false) - private int volunteerWarnings; - - @Column(name = "requester_warnings", nullable = false) - private int requesterWarnings; - - @Column(name = "volunteer_banned_until") - private Instant volunteerBannedUntil; - - public Long getUserId() { return userId; } - public void setUserId(Long userId) { this.userId = userId; } - public UserEntity getUser() { return user; } - public void setUser(UserEntity user) { this.user = user; } - public int getVolunteerWarnings() { return volunteerWarnings; } - public void setVolunteerWarnings(int volunteerWarnings) { this.volunteerWarnings = volunteerWarnings; } - public int getRequesterWarnings() { return requesterWarnings; } - public void setRequesterWarnings(int requesterWarnings) { this.requesterWarnings = requesterWarnings; } - public Instant getVolunteerBannedUntil() { return volunteerBannedUntil; } - public void setVolunteerBannedUntil(Instant volunteerBannedUntil) { this.volunteerBannedUntil = volunteerBannedUntil; } -} diff --git a/src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java b/src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java deleted file mode 100644 index b1f9278..0000000 --- a/src/main/java/goodroad/volunteer/repository/VolunteerUserStateRepo.java +++ /dev/null @@ -1,6 +0,0 @@ -package goodroad.volunteer.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface VolunteerUserStateRepo extends JpaRepository { -} diff --git a/src/main/resources/db/migration/V2__volunteer_schema.sql b/src/main/resources/db/migration/V2__volunteer_schema.sql index c38eb8c..b9cadc2 100644 --- a/src/main/resources/db/migration/V2__volunteer_schema.sql +++ b/src/main/resources/db/migration/V2__volunteer_schema.sql @@ -30,13 +30,6 @@ create table if not exists volunteer_application_photo ( create index if not exists ix_volunteer_application_photo_application on volunteer_application_photo(application_id); -create table if not exists volunteer_user_state ( - user_id bigint primary key references users(id) on delete cascade, - volunteer_warnings integer not null default 0 check (volunteer_warnings between 0 and 3), - requester_warnings integer not null default 0 check (requester_warnings between 0 and 3), - volunteer_banned_until timestamptz -); - 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, @@ -58,10 +51,6 @@ create table if not exists help_request ( requester_finished_at timestamptz, volunteer_finished_at timestamptz, completed_at timestamptz, - requester_latitude double precision check (requester_latitude is null or requester_latitude between -90 and 90), - requester_longitude double precision check (requester_longitude is null or requester_longitude between -180 and 180), - volunteer_latitude double precision check (volunteer_latitude is null or volunteer_latitude between -90 and 90), - volunteer_longitude double precision check (volunteer_longitude is null or volunteer_longitude between -180 and 180), planned_route_points text ); @@ -71,39 +60,3 @@ 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); - -create table if not exists volunteer_complaint ( - id bigint generated by default as identity primary key, - request_id bigint not null references help_request(id) on delete cascade, - author_id bigint not null references users(id) on delete cascade, - target_id bigint not null references users(id) on delete cascade, - text varchar(2000) not null, - status varchar(16) not null check (status in ('PENDING', 'RESOLVED')), - guilty_user_id bigint references users(id) on delete set null, - moderator_id bigint references users(id) on delete set null, - moderator_comment varchar(1000), - created_at timestamptz not null default now(), - resolved_at timestamptz -); - -create index if not exists ix_volunteer_complaint_status_created - on volunteer_complaint(status, created_at asc); - -create table if not exists volunteer_sos_notification ( - id bigint generated by default as identity primary key, - request_id bigint not null references help_request(id) on delete cascade, - sender_id bigint not null references users(id) on delete cascade, - reason varchar(64) not null, - comment varchar(1000), - status varchar(16) not null default 'OPEN' - check (status in ('OPEN', 'CONFIRMED', 'FALSE_ALARM', 'RESOLVED')), - moderator_id bigint references users(id) on delete set null, - moderator_comment varchar(1000), - created_at timestamptz not null default now(), - resolved_at timestamptz -); - -create index if not exists ix_volunteer_sos_created - on volunteer_sos_notification(created_at desc); -create index if not exists ix_volunteer_sos_status_created - on volunteer_sos_notification(status, created_at desc); From 8a3fc4e699960853f03feb8b0206204f68de9950 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 15:55:13 +0300 Subject: [PATCH 12/16] refactor: remove sos complaints and response penalty from volunteering service --- .../goodroad/volunteer/VolunteerService.java | 435 ++++-------------- 1 file changed, 78 insertions(+), 357 deletions(-) diff --git a/src/main/java/goodroad/volunteer/VolunteerService.java b/src/main/java/goodroad/volunteer/VolunteerService.java index 6f019f0..f2d656e 100644 --- a/src/main/java/goodroad/volunteer/VolunteerService.java +++ b/src/main/java/goodroad/volunteer/VolunteerService.java @@ -18,74 +18,54 @@ import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; -import java.time.temporal.ChronoUnit; import java.util.*; @Service public class VolunteerService { - private static final int VOLUNTEER_CANCEL_PENALTY = 50; 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 VolunteerUserStateRepo states; private final HelpRequestRepo requests; - private final VolunteerComplaintRepo complaints; - private final SosNotificationRepo sosNotifications; private final StorageService storageService; public VolunteerService( UserRepo users, VolunteerApplicationRepo applications, VolunteerApplicationPhotoRepo applicationPhotos, - VolunteerUserStateRepo states, HelpRequestRepo requests, - VolunteerComplaintRepo complaints, - SosNotificationRepo sosNotifications, StorageService storageService ) { this.users = users; this.applications = applications; this.applicationPhotos = applicationPhotos; - this.states = states; this.requests = requests; - this.complaints = complaints; - this.sosNotifications = sosNotifications; this.storageService = storageService; } - public record VolunteerMenuResp(boolean volunteer, String applicationStatus, String rejectReason, Instant volunteerBannedUntil) {} + 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 SosReq(String comment) {} - public record LocationReq(Double latitude, Double longitude) {} public record RoutePointReq(Double latitude, Double longitude) {} public record WalkRouteReq( @JsonProperty("points") @JsonAlias("encodedPoints") String encodedPoints, @JsonProperty("routePoints") List routePoints ) {} - public record ComplaintReq(String text) {} - public record ComplaintResp(String id, String requestId, String authorId, String targetId, String text, String status, String moderatorComment, Instant createdAt, Instant resolvedAt) {} - public record ResolveComplaintReq(String guiltyUserId, String moderatorComment) {} - public record SosResp(String id, String requestId, String reason, String comment, String status, String moderatorComment, String requesterName, String volunteerName, String requesterPhone, String volunteerPhone, String requesterSocial, String volunteerSocial, Instant createdAt, Instant resolvedAt) {} - public record ResolveSosReq(String moderatorComment) {} @Transactional(readOnly = true) public VolunteerMenuResp getMenu(String phoneFromAuth) { UserEntity user = findCurrent(phoneFromAuth); - VolunteerUserStateEntity state = states.findById(user.getId()).orElse(null); VolunteerApplicationEntity last = applications.findFirstByApplicantIdOrderByCreatedAtDesc(user.getId()).orElse(null); return new VolunteerMenuResp( isVolunteer(user), last == null ? null : last.getStatus(), - last == null ? null : last.getModeratorComment(), - state == null ? null : state.getVolunteerBannedUntil() + last == null ? null : last.getModeratorComment() ); } @@ -149,7 +129,6 @@ public VolunteerApplicationResp approveApplication(String moderatorPhone, String app.getApplicant().setRole(Role.VOLUNTEER.name()); users.save(app.getApplicant()); applications.save(app); - getOrCreateState(app.getApplicant()); return toApplicationResp(app); } @@ -202,7 +181,6 @@ public List listMyWards(String phoneFromAuth) { @Transactional(readOnly = true) public List listAvailableRequests(String phoneFromAuth, Double latitude, Double longitude) { UserEntity volunteer = requireVolunteer(phoneFromAuth); - requireNotBanned(volunteer); return requests.findByStatusOrderByDateAscTimeAscCreatedAtAsc("OPEN").stream() .filter(request -> !request.getRequester().getId().equals(volunteer.getId())) .sorted(Comparator.comparing(HelpRequestEntity::getDate).thenComparing(HelpRequestEntity::getTime)) @@ -249,7 +227,6 @@ public void deleteOwnRequest(String phoneFromAuth, String id) { @Transactional public HelpRequestResp acceptRequest(String phoneFromAuth, String id) { UserEntity volunteer = requireVolunteer(phoneFromAuth); - requireNotBanned(volunteer); HelpRequestEntity request = findRequest(id); if (!"OPEN".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_NOT_OPEN", "Help request is not open"); @@ -271,7 +248,6 @@ public HelpRequestResp withdrawResponse(String phoneFromAuth, String id) { if (!"ACCEPTED".equals(request.getStatus())) { throw new ApiException(HttpStatus.CONFLICT, "REQUEST_CANNOT_WITHDRAW", "Response cannot be withdrawn"); } - subtractPoints(volunteer, VOLUNTEER_CANCEL_PENALTY); request.setVolunteer(null); request.setAcceptedAt(null); request.setStatus("OPEN"); @@ -291,9 +267,15 @@ public HelpRequestResp startWalk(String phoneFromAuth, String id, WalkRouteReq r } Instant now = Instant.now(); if (request.getRequester().getId().equals(user.getId())) { - request.setRequesterStartedAt(now); + if (request.getRequesterStartedAt() == null) { + requireNoActiveRequesterWalk(user, request); + request.setRequesterStartedAt(now); + } } else { - request.setVolunteerStartedAt(now); + if (request.getVolunteerStartedAt() == null) { + requireNoActiveVolunteerWalk(user, request); + request.setVolunteerStartedAt(now); + } } if (request.getRequesterStartedAt() != null && request.getVolunteerStartedAt() != null && request.getStartedAt() == null) { request.setStartedAt(now); @@ -313,39 +295,6 @@ public HelpRequestResp setWalkRoute(String phoneFromAuth, String id, WalkRouteRe return toHelpResp(requests.save(request), user, true); } - @Transactional - public HelpRequestResp updateLocation(String phoneFromAuth, String id, LocationReq req) { - 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"); - } - if (req == null) { - throw bad("LOCATION_INVALID", "Location is invalid"); - } - validateCoordinates(req.latitude(), req.longitude()); - if (request.getRequester().getId().equals(user.getId())) { - request.setRequesterLatitude(req.latitude()); - request.setRequesterLongitude(req.longitude()); - } else { - request.setVolunteerLatitude(req.latitude()); - request.setVolunteerLongitude(req.longitude()); - } - if (isFarFromPlannedRoute(request, req.latitude(), req.longitude())) { - createSos(request, user, "ROUTE_DEVIATION", "Participant is more than one kilometer away from the planned route"); - } - return toHelpResp(requests.save(request), user, true); - } - - @Transactional - public SosResp sendSos(String phoneFromAuth, String id, SosReq req) { - UserEntity user = findCurrent(phoneFromAuth); - HelpRequestEntity request = findRequest(id); - requireParticipant(request, user); - return createSos(request, user, "MANUAL", req == null ? null : req.comment()); - } - @Transactional public HelpRequestResp finishWalk(String phoneFromAuth, String id) { UserEntity user = findCurrent(phoneFromAuth); @@ -355,9 +304,6 @@ public HelpRequestResp finishWalk(String phoneFromAuth, String id) { throw new ApiException(HttpStatus.CONFLICT, "WALK_NOT_STARTED", "Walk is not started"); } Instant now = Instant.now(); - if (Duration.between(request.getStartedAt(), now).toHours() >= 5) { - createSos(request, user, "TIME_LIMIT", "Walk lasts more than five hours"); - } if (request.getRequester().getId().equals(user.getId())) { request.setRequesterFinishedAt(now); } else { @@ -373,112 +319,37 @@ public HelpRequestResp finishWalk(String phoneFromAuth, String id) { return toHelpResp(requests.save(request), user, true); } - @Transactional - public ComplaintResp createComplaint(String phoneFromAuth, String requestId, ComplaintReq req) { - UserEntity author = findCurrent(phoneFromAuth); - HelpRequestEntity request = findRequest(requestId); - requireParticipant(request, author); - String text = InputRules.trimToNull(req == null ? null : req.text()); - if (text == null) { - throw bad("COMPLAINT_TEXT_EMPTY", "Complaint text is empty"); - } - if (request.getVolunteer() == null) { - throw new ApiException(HttpStatus.CONFLICT, "REQUEST_HAS_NO_VOLUNTEER", "Complaint is available after a volunteer accepts the request"); + 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"); } - VolunteerComplaintEntity complaint = new VolunteerComplaintEntity(); - complaint.setRequest(request); - complaint.setAuthor(author); - complaint.setTarget(request.getRequester().getId().equals(author.getId()) ? request.getVolunteer() : request.getRequester()); - complaint.setText(text); - return toComplaintResp(complaints.save(complaint)); - } - - @Transactional(readOnly = true) - public List listPendingComplaints() { - return complaints.findByStatusOrderByCreatedAtAsc("PENDING").stream().map(this::toComplaintResp).toList(); } - @Transactional - public ComplaintResp resolveComplaint(String moderatorPhone, String id, ResolveComplaintReq req) { - UserEntity moderator = requireModerator(moderatorPhone); - VolunteerComplaintEntity complaint = complaints.findById(parseId(id, "COMPLAINT_ID_INVALID", "Complaint id is invalid")) - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "COMPLAINT_NOT_FOUND", "Complaint not found")); - UserEntity guilty = users.findById(parseId(req == null ? null : req.guiltyUserId(), "GUILTY_USER_ID_INVALID", "Guilty user id is invalid")) - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "GUILTY_USER_NOT_FOUND", "Guilty user not found")); - applyPenalty(complaint.getRequest(), guilty); - complaint.setStatus("RESOLVED"); - complaint.setGuiltyUser(guilty); - complaint.setModerator(moderator); - complaint.setModeratorComment(InputRules.trimToNull(req.moderatorComment())); - complaint.setResolvedAt(Instant.now()); - return toComplaintResp(complaints.save(complaint)); - } - - @Transactional(readOnly = true) - public List listSosNotifications() { - return sosNotifications.findByStatusInOrderByCreatedAtDesc(List.of("OPEN", "CONFIRMED")).stream().map(this::toSosResp).toList(); - } - - @Transactional(readOnly = true) - public List listAllSosNotifications() { - return sosNotifications.findAllByOrderByCreatedAtDesc().stream().map(this::toSosResp).toList(); - } - - @Transactional - public SosResp confirmSos(String moderatorPhone, String id, ResolveSosReq req) { - return updateSosStatus(moderatorPhone, id, "CONFIRMED", req); - } - - @Transactional - public SosResp markSosFalseAlarm(String moderatorPhone, String id, ResolveSosReq req) { - return updateSosStatus(moderatorPhone, id, "FALSE_ALARM", req); - } - - @Transactional - public SosResp resolveSos(String moderatorPhone, String id, ResolveSosReq req) { - return updateSosStatus(moderatorPhone, id, "RESOLVED", req); + 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 SosResp updateSosStatus(String moderatorPhone, String id, String status, ResolveSosReq req) { - UserEntity moderator = requireModerator(moderatorPhone); - SosNotificationEntity sos = sosNotifications.findById(parseId(id, "SOS_ID_INVALID", "SOS id is invalid")) - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SOS_NOT_FOUND", "SOS notification not found")); - if ("RESOLVED".equals(sos.getStatus()) || "FALSE_ALARM".equals(sos.getStatus())) { - throw new ApiException(HttpStatus.CONFLICT, "SOS_ALREADY_CLOSED", "SOS notification is already closed"); - } - if ("FALSE_ALARM".equals(status) && "CONFIRMED".equals(sos.getStatus())) { - throw new ApiException(HttpStatus.CONFLICT, "SOS_ALREADY_CONFIRMED", "Confirmed SOS cannot be marked as false alarm"); - } - sos.setStatus(status); - sos.setModerator(moderator); - sos.setModeratorComment(InputRules.trimToNull(req == null ? null : req.moderatorComment())); - if ("RESOLVED".equals(status) || "FALSE_ALARM".equals(status)) { - sos.setResolvedAt(Instant.now()); - } - return toSosResp(sosNotifications.save(sos)); + private boolean isOtherActiveRequesterWalk(HelpRequestEntity request, HelpRequestEntity current) { + return !request.getId().equals(current.getId()) + && request.getStartedAt() != null + && request.getRequesterFinishedAt() == null; } - private void fillRequest(HelpRequestEntity request, HelpRequestReq req) { - request.setFromAddress(InputRules.requireAddressText(req.fromAddress(), "FROM_ADDRESS_INVALID", "From address")); - request.setToAddress(InputRules.requireAddressText(req.toAddress(), "TO_ADDRESS_INVALID", "To address")); - request.setDate(parseDate(req.date())); - request.setTime(parseTime(req.time())); - String phone = Crypto.normPhone(req.phone()); - if (phone.isEmpty()) { - throw bad("PHONE_INVALID", "Phone is invalid"); - } - request.setPhone(phone); - request.setSocialNickname(InputRules.trimToNull(req.socialNickname())); - String comment = InputRules.trimToNull(req.comment()); - if (comment == null) { - throw bad("HELP_COMMENT_EMPTY", "Help comment is empty"); - } - request.setComment(comment); + 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().toString(), + app.getId() == null ? null : app.getId().toString(), app.getApplicant().getId().toString(), joinName(app.getApplicant()), app.getDobroUrl(), @@ -492,17 +363,21 @@ private VolunteerApplicationResp toApplicationResp(VolunteerApplicationEntity ap ); } - private HelpRequestResp toHelpResp(HelpRequestEntity request, UserEntity viewer, boolean allowOwnContacts) { - boolean participant = isParticipant(request, viewer); - boolean contactsVisible = allowOwnContacts && participant && request.getVolunteer() != null; - boolean canStart = participant && "ACCEPTED".equals(request.getStatus()) && scheduledTime(request).minus(30, ChronoUnit.MINUTES).isBefore(Instant.now()); + 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().toString(), + request.getId() == null ? null : request.getId().toString(), request.getRequester().getId().toString(), request.getVolunteer() == null ? null : request.getVolunteer().getId().toString(), request.getFromAddress(), request.getToAddress(), - request.getDate().format(DATE_FORMAT), + DATE_FORMAT.format(request.getDate()), request.getTime().toString(), contactsVisible ? request.getPhone() : null, contactsVisible ? request.getSocialNickname() : null, @@ -511,30 +386,41 @@ private HelpRequestResp toHelpResp(HelpRequestEntity request, UserEntity viewer, contactsVisible, canStart, request.getStartedAt() != null, - request.getCompletedAt() != null, + "COMPLETED".equals(request.getStatus()), request.getCreatedAt() ); } - private ComplaintResp toComplaintResp(VolunteerComplaintEntity complaint) { - return new ComplaintResp( - complaint.getId().toString(), - complaint.getRequest().getId().toString(), - complaint.getAuthor().getId().toString(), - complaint.getTarget().getId().toString(), - complaint.getText(), - complaint.getStatus(), - complaint.getModeratorComment(), - complaint.getCreatedAt(), - complaint.getResolvedAt() - ); + 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 = extractRoutePoints(req); + List points = readRoutePoints(req); if (points.size() < 2) { - throw bad("ROUTE_POINTS_INVALID", "Route must contain at least two valid points"); + throw bad("ROUTE_POINTS_INVALID", "Route must contain at least two points"); } StringJoiner joiner = new StringJoiner(";"); for (RoutePointReq point : points) { @@ -544,36 +430,33 @@ private void saveWalkRoute(HelpRequestEntity request, WalkRouteReq req) { request.setPlannedRoutePoints(joiner.toString()); } - private List extractRoutePoints(WalkRouteReq req) { + private List readRoutePoints(WalkRouteReq req) { if (req == null) { - throw bad("ROUTE_EMPTY", "Route is empty"); + 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) { - return decodePolyline(encoded); + if (encoded == null) { + throw bad("ROUTE_POINTS_INVALID", "Route is empty"); } - throw bad("ROUTE_EMPTY", "Route is empty"); + return decodePolyline(encoded); } private List decodePolyline(String encoded) { List points = new ArrayList<>(); int index = 0; int lat = 0; - int lon = 0; + int lng = 0; while (index < encoded.length()) { int[] latResult = decodePolylineValue(encoded, index); lat += latResult[0]; index = latResult[1]; - if (index >= encoded.length()) { - throw bad("ROUTE_POINTS_INVALID", "Encoded route is invalid"); - } - int[] lonResult = decodePolylineValue(encoded, index); - lon += lonResult[0]; - index = lonResult[1]; - points.add(new RoutePointReq(lat / 100000.0, lon / 100000.0)); + int[] lngResult = decodePolylineValue(encoded, index); + lng += lngResult[0]; + index = lngResult[1]; + points.add(new RoutePointReq(lat / 1e5, lng / 1e5)); } return points; } @@ -591,69 +474,7 @@ private int[] decodePolylineValue(String encoded, int index) { shift += 5; } while (b >= 0x20); int delta = (result & 1) != 0 ? ~(result >> 1) : result >> 1; - return new int[]{delta, index}; - } - - private boolean isFarFromPlannedRoute(HelpRequestEntity request, double latitude, double longitude) { - List route = parseStoredRoute(request.getPlannedRoutePoints()); - if (route.size() < 2) { - return false; - } - double minDistance = Double.MAX_VALUE; - for (int i = 1; i < route.size(); i++) { - RoutePointReq a = route.get(i - 1); - RoutePointReq b = route.get(i); - minDistance = Math.min(minDistance, distanceToSegmentMeters(latitude, longitude, a.latitude(), a.longitude(), b.latitude(), b.longitude())); - } - return minDistance > 1000; - } - - private List parseStoredRoute(String raw) { - String value = InputRules.trimToNull(raw); - if (value == null) { - return List.of(); - } - List points = new ArrayList<>(); - for (String part : value.split(";")) { - String[] coords = part.split(","); - if (coords.length != 2) { - return List.of(); - } - try { - points.add(new RoutePointReq(Double.parseDouble(coords[0]), Double.parseDouble(coords[1]))); - } catch (NumberFormatException e) { - return List.of(); - } - } - return points; - } - - private double distanceToSegmentMeters(double pointLat, double pointLon, double startLat, double startLon, double endLat, double endLon) { - double refLat = Math.toRadians(pointLat); - double px = lonToMeters(pointLon, refLat); - double py = latToMeters(pointLat); - double ax = lonToMeters(startLon, refLat); - double ay = latToMeters(startLat); - double bx = lonToMeters(endLon, refLat); - double by = latToMeters(endLat); - double dx = bx - ax; - double dy = by - ay; - if (dx == 0 && dy == 0) { - return Math.hypot(px - ax, py - ay); - } - double t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy); - t = Math.max(0, Math.min(1, t)); - double closestX = ax + t * dx; - double closestY = ay + t * dy; - return Math.hypot(px - closestX, py - closestY); - } - - private double latToMeters(double lat) { - return Math.toRadians(lat) * 6371000.0; - } - - private double lonToMeters(double lon, double refLat) { - return Math.toRadians(lon) * 6371000.0 * Math.cos(refLat); + return new int[] { delta, index }; } private void validateCoordinates(Double latitude, Double longitude) { @@ -663,99 +484,6 @@ private void validateCoordinates(Double latitude, Double longitude) { } } - private SosResp createSos(HelpRequestEntity request, UserEntity sender, String reason, String comment) { - SosNotificationEntity sos = new SosNotificationEntity(); - sos.setRequest(request); - sos.setSender(sender); - sos.setReason(reason); - sos.setComment(InputRules.trimToNull(comment)); - sos.setStatus("OPEN"); - return toSosResp(sosNotifications.save(sos)); - } - - private SosResp toSosResp(SosNotificationEntity sos) { - HelpRequestEntity request = sos.getRequest(); - return new SosResp( - sos.getId().toString(), - request.getId().toString(), - sos.getReason(), - sos.getComment(), - sos.getStatus(), - sos.getModeratorComment(), - joinName(request.getRequester()), - request.getVolunteer() == null ? null : joinName(request.getVolunteer()), - request.getPhone(), - volunteerContact(request.getVolunteer(), true), - request.getSocialNickname(), - volunteerContact(request.getVolunteer(), false), - sos.getCreatedAt(), - sos.getResolvedAt() - ); - } - - private String volunteerContact(UserEntity volunteer, boolean phone) { - if (volunteer == null) { - return null; - } - return applications.findFirstByApplicantIdOrderByCreatedAtDesc(volunteer.getId()) - .filter(app -> "APPROVED".equals(app.getStatus())) - .map(app -> phone ? app.getPhone() : app.getSocialNickname()) - .orElse(null); - } - - private double distanceMeters(double lat1, double lon1, double lat2, double lon2) { - double radius = 6371000.0; - double dLat = Math.toRadians(lat2 - lat1); - double dLon = Math.toRadians(lon2 - lon1); - double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) - + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) - * Math.sin(dLon / 2) * Math.sin(dLon / 2); - return 2 * radius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } - - private void applyPenalty(HelpRequestEntity request, UserEntity guilty) { - VolunteerUserStateEntity state = getOrCreateState(guilty); - boolean guiltyVolunteer = request.getVolunteer() != null && request.getVolunteer().getId().equals(guilty.getId()); - if (guiltyVolunteer) { - int warnings = state.getVolunteerWarnings() + 1; - state.setVolunteerWarnings(warnings); - if (warnings == 1) { - subtractPoints(guilty, 50); - } else if (warnings == 2) { - subtractPoints(guilty, 75); - } else { - subtractPoints(guilty, 100); - state.setVolunteerWarnings(0); - state.setVolunteerBannedUntil(Instant.now().plus(Duration.ofDays(7))); - } - } else { - int warnings = state.getRequesterWarnings() + 1; - state.setRequesterWarnings(warnings); - if (warnings == 1) { - subtractPoints(guilty, 25); - } else if (warnings == 2) { - subtractPoints(guilty, 50); - } else { - subtractPoints(guilty, 75); - state.setRequesterWarnings(0); - } - } - states.save(state); - } - - private VolunteerUserStateEntity getOrCreateState(UserEntity user) { - return states.findById(user.getId()).orElseGet(() -> { - VolunteerUserStateEntity state = new VolunteerUserStateEntity(); - state.setUser(user); - return states.save(state); - }); - } - - private void subtractPoints(UserEntity user, int points) { - user.setTotalPoints(Math.max(0, user.getTotalPoints() - points)); - users.save(user); - } - private UserEntity findCurrent(String phoneFromAuth) { String phoneNorm = Crypto.normPhone(phoneFromAuth); if (phoneNorm.isEmpty()) { @@ -781,13 +509,6 @@ private UserEntity requireVolunteer(String phoneFromAuth) { return user; } - private void requireNotBanned(UserEntity volunteer) { - VolunteerUserStateEntity state = states.findById(volunteer.getId()).orElse(null); - if (state != null && state.getVolunteerBannedUntil() != null && state.getVolunteerBannedUntil().isAfter(Instant.now())) { - throw new ApiException(HttpStatus.FORBIDDEN, "VOLUNTEER_BANNED", "Volunteer is temporarily banned"); - } - } - private boolean isVolunteer(UserEntity user) { return Role.VOLUNTEER.name().equals(user.getRole()); } From 3f1e0d2ed0e341789a46ba37f079d5d49f25dcfa Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 15:57:07 +0300 Subject: [PATCH 13/16] refactor: remove sos and complaint endpoints from volunteering api --- .../volunteer/VolunteerController.java | 27 ---------- .../VolunteerModerationController.java | 52 ------------------- 2 files changed, 79 deletions(-) diff --git a/src/main/java/goodroad/volunteer/VolunteerController.java b/src/main/java/goodroad/volunteer/VolunteerController.java index 5117a9f..91becf6 100644 --- a/src/main/java/goodroad/volunteer/VolunteerController.java +++ b/src/main/java/goodroad/volunteer/VolunteerController.java @@ -109,31 +109,4 @@ public VolunteerService.HelpRequestResp startWalk( public VolunteerService.HelpRequestResp finishWalk(Authentication authentication, @PathVariable String id) { return service.finishWalk(authentication.getName(), id); } - - @PostMapping("/requests/{id}/location") - public VolunteerService.HelpRequestResp updateLocation( - Authentication authentication, - @PathVariable String id, - @RequestBody VolunteerService.LocationReq req - ) { - return service.updateLocation(authentication.getName(), id, req); - } - - @PostMapping("/requests/{id}/sos") - public VolunteerService.SosResp sendSos( - Authentication authentication, - @PathVariable String id, - @RequestBody(required = false) VolunteerService.SosReq req - ) { - return service.sendSos(authentication.getName(), id, req); - } - - @PostMapping("/requests/{id}/complaints") - public VolunteerService.ComplaintResp createComplaint( - Authentication authentication, - @PathVariable String id, - @RequestBody VolunteerService.ComplaintReq req - ) { - return service.createComplaint(authentication.getName(), id, req); - } } diff --git a/src/main/java/goodroad/volunteer/VolunteerModerationController.java b/src/main/java/goodroad/volunteer/VolunteerModerationController.java index 129193a..53b0778 100644 --- a/src/main/java/goodroad/volunteer/VolunteerModerationController.java +++ b/src/main/java/goodroad/volunteer/VolunteerModerationController.java @@ -30,56 +30,4 @@ public VolunteerService.VolunteerApplicationResp rejectApplication( ) { return service.rejectApplication(authentication.getName(), id, req); } - - @GetMapping("/complaints/pending") - public List listPendingComplaints() { - return service.listPendingComplaints(); - } - - @PostMapping("/complaints/{id}/resolve") - public VolunteerService.ComplaintResp resolveComplaint( - Authentication authentication, - @PathVariable String id, - @RequestBody VolunteerService.ResolveComplaintReq req - ) { - return service.resolveComplaint(authentication.getName(), id, req); - } - - @GetMapping("/sos") - public List listSosNotifications() { - return service.listSosNotifications(); - } - - @GetMapping("/sos/all") - public List listAllSosNotifications() { - return service.listAllSosNotifications(); - } - - @PostMapping("/sos/{id}/confirm") - public VolunteerService.SosResp confirmSos( - Authentication authentication, - @PathVariable String id, - @RequestBody(required = false) VolunteerService.ResolveSosReq req - ) { - return service.confirmSos(authentication.getName(), id, req); - } - - @PostMapping("/sos/{id}/false-alarm") - public VolunteerService.SosResp markSosFalseAlarm( - Authentication authentication, - @PathVariable String id, - @RequestBody(required = false) VolunteerService.ResolveSosReq req - ) { - return service.markSosFalseAlarm(authentication.getName(), id, req); - } - - @PostMapping("/sos/{id}/resolve") - public VolunteerService.SosResp resolveSos( - Authentication authentication, - @PathVariable String id, - @RequestBody(required = false) VolunteerService.ResolveSosReq req - ) { - return service.resolveSos(authentication.getName(), id, req); - } } - From 16030f5a65df4fc0dcacfb0a39b948e7df70c226 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 15:57:43 +0300 Subject: [PATCH 14/16] test: cover volunteering flow without sos complaints and penalties --- .../volunteer/VolunteerControllerTest.java | 69 +---- .../VolunteerModerationControllerTest.java | 122 -------- .../volunteer/VolunteerServiceTest.java | 269 +++--------------- 3 files changed, 51 insertions(+), 409 deletions(-) diff --git a/src/test/java/goodroad/volunteer/VolunteerControllerTest.java b/src/test/java/goodroad/volunteer/VolunteerControllerTest.java index 33a4a91..f0233c4 100644 --- a/src/test/java/goodroad/volunteer/VolunteerControllerTest.java +++ b/src/test/java/goodroad/volunteer/VolunteerControllerTest.java @@ -29,7 +29,7 @@ class VolunteerControllerTest { void shouldReturnVolunteerMenu() throws Exception { MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); when(service.getMenu("+79990000001")) - .thenReturn(new VolunteerService.VolunteerMenuResp(true, "APPROVED", null, null)); + .thenReturn(new VolunteerService.VolunteerMenuResp(true, "APPROVED", null)); mvc.perform(get("/volunteer/menu").principal(principal("+79990000001"))) .andExpect(status().isOk()) @@ -157,20 +157,15 @@ void shouldUseHelpRequestEndpoints() throws Exception { } @Test - void shouldUseWalkAndSosEndpoints() throws Exception { + void shouldUseWalkEndpoints() throws Exception { MockMvc mvc = standaloneSetup(new VolunteerController(service)).build(); Principal user = principal("+79990000001"); - VolunteerService.HelpRequestResp active = helpRequest("20", "2", "IN_PROGRESS", true, true, false); + VolunteerService.HelpRequestResp active = helpRequest("20", "2", "ACCEPTED", true, true, false); VolunteerService.HelpRequestResp completed = helpRequest("20", "2", "COMPLETED", true, true, true); - VolunteerService.SosResp sos = sos("30", "20", "MANUAL", "OPEN"); - VolunteerService.ComplaintResp complaint = complaint("40", "20", "PENDING"); 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.updateLocation(eq("+79990000001"), eq("20"), any(VolunteerService.LocationReq.class))).thenReturn(active); when(service.finishWalk("+79990000001", "20")).thenReturn(completed); - when(service.sendSos(eq("+79990000001"), eq("20"), any(VolunteerService.SosReq.class))).thenReturn(sos); - when(service.createComplaint(eq("+79990000001"), eq("20"), any(VolunteerService.ComplaintReq.class))).thenReturn(complaint); mvc.perform(post("/volunteer/requests/20/route") .principal(user) @@ -184,52 +179,11 @@ void shouldUseWalkAndSosEndpoints() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(routeJson())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("IN_PROGRESS")); - - mvc.perform(post("/volunteer/requests/20/location") - .principal(user) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "latitude": 59.93, - "longitude": 30.31 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value("20")); - - mvc.perform(post("/volunteer/requests/20/sos") - .principal(user) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "comment": "Нужна помощь" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("OPEN")); - - mvc.perform(post("/volunteer/requests/20/complaints") - .principal(user) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "text": "Волонтер не пришел" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("PENDING")); + .andExpect(jsonPath("$.status").value("ACCEPTED")); mvc.perform(post("/volunteer/requests/20/finish").principal(user)) .andExpect(status().isOk()) .andExpect(jsonPath("$.completed").value(true)); - - verify(service).updateLocation(eq("+79990000001"), eq("20"), argThat(req -> - Double.valueOf(59.93).equals(req.latitude()) && Double.valueOf(30.31).equals(req.longitude()) - )); - verify(service).sendSos(eq("+79990000001"), eq("20"), argThat(req -> - "Нужна помощь".equals(req.comment()) - )); } private Principal principal(String phone) { @@ -261,21 +215,6 @@ private VolunteerService.HelpRequestResp helpRequest( ); } - private VolunteerService.SosResp sos(String id, String requestId, String reason, String status) { - return new VolunteerService.SosResp( - id, requestId, reason, "Нужна помощь", status, null, - "Иван Петров", "Анна Иванова", "79990000001", "79990000002", "@user", "@volunteer", - Instant.parse("2026-05-01T10:00:00Z"), null - ); - } - - private VolunteerService.ComplaintResp complaint(String id, String requestId, String status) { - return new VolunteerService.ComplaintResp( - id, requestId, "1", "2", "Волонтер не пришел", status, null, - Instant.parse("2026-05-01T10:00:00Z"), null - ); - } - private String helpRequestJson() { return """ { diff --git a/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java b/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java index 4799976..dd579de 100644 --- a/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java +++ b/src/test/java/goodroad/volunteer/VolunteerModerationControllerTest.java @@ -63,112 +63,6 @@ void shouldModerateVolunteerApplications() throws Exception { )); } - @Test - void shouldModerateComplaints() throws Exception { - MockMvc mvc = standaloneSetup(new VolunteerModerationController(service)).build(); - Principal moderator = principal("+79990000151"); - VolunteerService.ComplaintResp pending = complaint("40", "PENDING", null); - VolunteerService.ComplaintResp resolved = complaint("40", "RESOLVED", "Виновный предупрежден"); - - when(service.listPendingComplaints()).thenReturn(List.of(pending)); - when(service.resolveComplaint(eq("+79990000151"), eq("40"), any(VolunteerService.ResolveComplaintReq.class))) - .thenReturn(resolved); - - mvc.perform(get("/volunteer/moderation/complaints/pending").principal(moderator)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value("40")) - .andExpect(jsonPath("$[0].status").value("PENDING")); - - mvc.perform(post("/volunteer/moderation/complaints/40/resolve") - .principal(moderator) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "guiltyUserId": "2", - "moderatorComment": "Виновный предупрежден" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("RESOLVED")) - .andExpect(jsonPath("$.moderatorComment").value("Виновный предупрежден")); - - verify(service).resolveComplaint(eq("+79990000151"), eq("40"), argThat(req -> - "2".equals(req.guiltyUserId()) && "Виновный предупрежден".equals(req.moderatorComment()) - )); - } - - @Test - void shouldModerateSosNotifications() throws Exception { - MockMvc mvc = standaloneSetup(new VolunteerModerationController(service)).build(); - Principal moderator = principal("+79990000151"); - VolunteerService.SosResp open = sos("30", "OPEN", null); - VolunteerService.SosResp confirmed = sos("30", "CONFIRMED", "Связались с участниками"); - VolunteerService.SosResp falseAlarm = sos("31", "FALSE_ALARM", "Ошибочное нажатие"); - VolunteerService.SosResp resolved = sos("30", "RESOLVED", "Ситуация решена"); - - when(service.listSosNotifications()).thenReturn(List.of(open, confirmed)); - when(service.listAllSosNotifications()).thenReturn(List.of(open, confirmed, falseAlarm, resolved)); - when(service.confirmSos(eq("+79990000151"), eq("30"), any(VolunteerService.ResolveSosReq.class))) - .thenReturn(confirmed); - when(service.markSosFalseAlarm(eq("+79990000151"), eq("31"), any(VolunteerService.ResolveSosReq.class))) - .thenReturn(falseAlarm); - when(service.resolveSos(eq("+79990000151"), eq("30"), any(VolunteerService.ResolveSosReq.class))) - .thenReturn(resolved); - - mvc.perform(get("/volunteer/moderation/sos").principal(moderator)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].status").value("OPEN")) - .andExpect(jsonPath("$[1].status").value("CONFIRMED")); - - mvc.perform(get("/volunteer/moderation/sos/all").principal(moderator)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[2].status").value("FALSE_ALARM")) - .andExpect(jsonPath("$[3].status").value("RESOLVED")); - - mvc.perform(post("/volunteer/moderation/sos/30/confirm") - .principal(moderator) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "moderatorComment": "Связались с участниками" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("CONFIRMED")); - - mvc.perform(post("/volunteer/moderation/sos/31/false-alarm") - .principal(moderator) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "moderatorComment": "Ошибочное нажатие" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("FALSE_ALARM")); - - mvc.perform(post("/volunteer/moderation/sos/30/resolve") - .principal(moderator) - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "moderatorComment": "Ситуация решена" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("RESOLVED")); - - verify(service).confirmSos(eq("+79990000151"), eq("30"), argThat(req -> - "Связались с участниками".equals(req.moderatorComment()) - )); - verify(service).markSosFalseAlarm(eq("+79990000151"), eq("31"), argThat(req -> - "Ошибочное нажатие".equals(req.moderatorComment()) - )); - verify(service).resolveSos(eq("+79990000151"), eq("30"), argThat(req -> - "Ситуация решена".equals(req.moderatorComment()) - )); - } - private Principal principal(String phone) { return new UsernamePasswordAuthenticationToken(phone, "password"); } @@ -180,20 +74,4 @@ private VolunteerService.VolunteerApplicationResp application(String id, String Instant.parse("2026-05-01T10:00:00Z"), null ); } - - private VolunteerService.ComplaintResp complaint(String id, String status, String comment) { - return new VolunteerService.ComplaintResp( - id, "20", "1", "2", "Волонтер не пришел", status, comment, - Instant.parse("2026-05-01T10:00:00Z"), "RESOLVED".equals(status) ? Instant.parse("2026-05-01T11:00:00Z") : null - ); - } - - private VolunteerService.SosResp sos(String id, String status, String comment) { - return new VolunteerService.SosResp( - id, "20", "MANUAL", "Нужна помощь", status, comment, - "Иван Петров", "Анна Иванова", "79990000001", "79990000002", "@user", "@volunteer", - Instant.parse("2026-05-01T10:00:00Z"), - List.of("RESOLVED", "FALSE_ALARM").contains(status) ? Instant.parse("2026-05-01T11:00:00Z") : null - ); - } } diff --git a/src/test/java/goodroad/volunteer/VolunteerServiceTest.java b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java index 3b00c53..21b3611 100644 --- a/src/test/java/goodroad/volunteer/VolunteerServiceTest.java +++ b/src/test/java/goodroad/volunteer/VolunteerServiceTest.java @@ -1,5 +1,6 @@ package goodroad.volunteer; +import goodroad.api.ApiErrors.ApiException; import goodroad.security.Crypto; import goodroad.storage.StorageService; import goodroad.users.repository.UserEntity; @@ -35,18 +36,9 @@ class VolunteerServiceTest { @Mock private VolunteerApplicationPhotoRepo applicationPhotos; - @Mock - private VolunteerUserStateRepo states; - @Mock private HelpRequestRepo requests; - @Mock - private VolunteerComplaintRepo complaints; - - @Mock - private SosNotificationRepo sosNotifications; - @Mock private StorageService storageService; @@ -86,12 +78,9 @@ void shouldApproveApplicationAndPromoteUserToVolunteer() { UserEntity applicant = user(1L, "USER", "+79990000001"); UserEntity moderator = user(2L, "MODERATOR", "+79990000002"); VolunteerApplicationEntity application = application(20L, applicant, "PENDING"); - VolunteerUserStateEntity state = state(applicant); when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(moderator)); when(applications.findById(20L)).thenReturn(Optional.of(application)); when(applications.save(application)).thenReturn(application); - when(states.findById(1L)).thenReturn(Optional.empty()); - when(states.save(any(VolunteerUserStateEntity.class))).thenReturn(state); VolunteerService.VolunteerApplicationResp result = service.approveApplication("+79990000002", "20"); @@ -145,7 +134,7 @@ void shouldCreateHelpRequestAndHideContactsForNonParticipantVolunteer() { request.setCreatedAt(Instant.now()); return request; }); - when(requests.findById(30L)).thenAnswer(invocation -> Optional.of(helpRequest(30L, requester, null, "OPEN"))); + 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"); @@ -163,7 +152,6 @@ void shouldAcceptRequestAndShowContactsToVolunteer() { UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); HelpRequestEntity request = helpRequest(30L, requester, null, "OPEN"); when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(states.findById(2L)).thenReturn(Optional.empty()); when(requests.findById(30L)).thenReturn(Optional.of(request)); when(requests.save(request)).thenReturn(request); @@ -177,7 +165,7 @@ void shouldAcceptRequestAndShowContactsToVolunteer() { } @Test - void shouldWithdrawResponseAndSubtractFiftyPoints() { + void shouldWithdrawResponseWithoutPenalty() { UserEntity requester = user(1L, "USER", "+79990000001"); UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); volunteer.setTotalPoints(80); @@ -190,8 +178,8 @@ void shouldWithdrawResponseAndSubtractFiftyPoints() { assertEquals("OPEN", result.status()); assertNull(request.getVolunteer()); - assertEquals(30, volunteer.getTotalPoints()); - verify(users).save(volunteer); + assertEquals(80, volunteer.getTotalPoints()); + verify(users, never()).save(volunteer); } @Test @@ -204,6 +192,8 @@ void shouldStartWalkOnlyAfterBothParticipantsConfirm() { 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()); @@ -216,51 +206,45 @@ void shouldStartWalkOnlyAfterBothParticipantsConfirm() { } @Test - void shouldCreateSosWhenParticipantIsMoreThanOneKilometerAwayFromRoute() { + void shouldBlockRequesterFromStartingNewWalkBeforeStoppingPreviousOne() { UserEntity requester = user(1L, "USER", "+79990000001"); - UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - request.setStartedAt(Instant.now()); - request.setPlannedRoutePoints("59.93,30.31;59.93,30.32"); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(requests.findById(30L)).thenReturn(Optional.of(request)); - when(requests.save(request)).thenReturn(request); - when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); - when(sosNotifications.save(any(SosNotificationEntity.class))).thenAnswer(invocation -> { - SosNotificationEntity sos = invocation.getArgument(0); - sos.setId(100L); - sos.setCreatedAt(Instant.now()); - return sos; - }); + 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"); - service.updateLocation("+79990000002", "30", new VolunteerService.LocationReq(59.95, 30.31)); + 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)); - verify(sosNotifications).save(argThat(sos -> "ROUTE_DEVIATION".equals(sos.getReason()))); + ApiException ex = assertThrows(ApiException.class, () -> service.startWalk("+79990000001", "31", null)); + assertEquals("ACTIVE_WALK_EXISTS", ex.code()); } @Test - void shouldNotCreateSosWhenParticipantIsCloseToRoute() { - UserEntity requester = user(1L, "USER", "+79990000001"); + void shouldBlockVolunteerFromStartingNewWalkBeforeStoppingPreviousOne() { + UserEntity firstRequester = user(1L, "USER", "+79990000001"); + UserEntity secondRequester = user(3L, "USER", "+79990000003"); UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - request.setStartedAt(Instant.now()); - request.setPlannedRoutePoints("59.93,30.31;59.93,30.32"); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(requests.findById(30L)).thenReturn(Optional.of(request)); - when(requests.save(request)).thenReturn(request); + HelpRequestEntity active = helpRequest(30L, firstRequester, volunteer, "ACCEPTED"); + active.setStartedAt(Instant.now().minus(Duration.ofHours(1))); + HelpRequestEntity next = helpRequest(31L, secondRequester, volunteer, "ACCEPTED"); - service.updateLocation("+79990000002", "30", new VolunteerService.LocationReq(59.9305, 30.315)); + 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)); - verifyNoInteractions(sosNotifications); + ApiException ex = assertThrows(ApiException.class, () -> service.startWalk("+79990000002", "31", null)); + assertEquals("ACTIVE_WALK_EXISTS", ex.code()); } @Test - void shouldFinishWalkAndAddRewardAfterBothParticipantsFinish() { + void shouldCompleteWalkAndAddVolunteerPointsAfterBothParticipantsFinish() { UserEntity requester = user(1L, "USER", "+79990000001"); UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - volunteer.setTotalPoints(20); HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - request.setStartedAt(Instant.now()); + 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)); @@ -272,162 +256,35 @@ void shouldFinishWalkAndAddRewardAfterBothParticipantsFinish() { assertFalse(afterRequester.completed()); assertTrue(afterVolunteer.completed()); assertEquals("COMPLETED", request.getStatus()); - assertEquals(120, volunteer.getTotalPoints()); - verify(users).save(volunteer); - } - - @Test - void shouldApplyVolunteerComplaintPenaltyAndWeekBanOnThirdWarning() { - UserEntity requester = user(1L, "USER", "+79990000001"); - UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); - volunteer.setTotalPoints(200); - HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - VolunteerComplaintEntity complaint = complaint(40L, request, requester, volunteer); - VolunteerUserStateEntity state = state(volunteer); - state.setVolunteerWarnings(2); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); - when(complaints.findById(40L)).thenReturn(Optional.of(complaint)); - when(users.findById(2L)).thenReturn(Optional.of(volunteer)); - when(states.findById(2L)).thenReturn(Optional.of(state)); - when(states.save(state)).thenReturn(state); - when(complaints.save(complaint)).thenReturn(complaint); - - VolunteerService.ComplaintResp result = service.resolveComplaint( - "+79990000003", - "40", - new VolunteerService.ResolveComplaintReq("2", "Волонтер не пришел") - ); - - assertEquals("RESOLVED", result.status()); assertEquals(100, volunteer.getTotalPoints()); - assertEquals(0, state.getVolunteerWarnings()); - assertNotNull(state.getVolunteerBannedUntil()); - assertTrue(state.getVolunteerBannedUntil().isAfter(Instant.now().plus(Duration.ofDays(6)))); - } - - @Test - void shouldRejectAvailableRequestsForBannedVolunteer() { - UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - VolunteerUserStateEntity state = state(volunteer); - state.setVolunteerBannedUntil(Instant.now().plus(Duration.ofDays(1))); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000002"))).thenReturn(Optional.of(volunteer)); - when(states.findById(2L)).thenReturn(Optional.of(state)); - - assertThrows(RuntimeException.class, () -> service.listAvailableRequests("+79990000002", 59.93, 30.31)); - } - - - @Test - void shouldConfirmSosAndKeepItOpenUntilResolved() { - UserEntity requester = user(1L, "USER", "+79990000001"); - UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); - HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - SosNotificationEntity sos = sos(100L, request, requester, "MANUAL", "OPEN"); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); - when(sosNotifications.findById(100L)).thenReturn(Optional.of(sos)); - when(sosNotifications.save(sos)).thenReturn(sos); - when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); - - VolunteerService.SosResp result = service.confirmSos( - "+79990000003", - "100", - new VolunteerService.ResolveSosReq("Связались с участниками") - ); - - assertEquals("CONFIRMED", result.status()); - assertEquals("CONFIRMED", sos.getStatus()); - assertEquals(moderator, sos.getModerator()); - assertEquals("Связались с участниками", sos.getModeratorComment()); - assertNull(sos.getResolvedAt()); - } - - @Test - void shouldMarkSosAsFalseAlarmAndCloseIt() { - UserEntity requester = user(1L, "USER", "+79990000001"); - UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); - HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - SosNotificationEntity sos = sos(100L, request, requester, "MANUAL", "OPEN"); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); - when(sosNotifications.findById(100L)).thenReturn(Optional.of(sos)); - when(sosNotifications.save(sos)).thenReturn(sos); - when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); - - VolunteerService.SosResp result = service.markSosFalseAlarm( - "+79990000003", - "100", - new VolunteerService.ResolveSosReq("Ошибочное нажатие") - ); - - assertEquals("FALSE_ALARM", result.status()); - assertEquals("FALSE_ALARM", sos.getStatus()); - assertNotNull(sos.getResolvedAt()); - } - - @Test - void shouldResolveConfirmedSos() { - UserEntity requester = user(1L, "USER", "+79990000001"); - UserEntity volunteer = user(2L, "VOLUNTEER", "+79990000002"); - UserEntity moderator = user(3L, "MODERATOR", "+79990000003"); - HelpRequestEntity request = helpRequest(30L, requester, volunteer, "ACCEPTED"); - SosNotificationEntity sos = sos(100L, request, volunteer, "ROUTE_DEVIATION", "CONFIRMED"); - when(users.findByPhoneHash(Crypto.sha256Hex("79990000003"))).thenReturn(Optional.of(moderator)); - when(sosNotifications.findById(100L)).thenReturn(Optional.of(sos)); - when(sosNotifications.save(sos)).thenReturn(sos); - when(applications.findFirstByApplicantIdOrderByCreatedAtDesc(2L)).thenReturn(Optional.empty()); - - VolunteerService.SosResp result = service.resolveSos( - "+79990000003", - "100", - new VolunteerService.ResolveSosReq("Участники в безопасности") - ); - - assertEquals("RESOLVED", result.status()); - assertEquals("RESOLVED", sos.getStatus()); - assertNotNull(sos.getResolvedAt()); - } - - @Test - void shouldListOnlyOpenAndConfirmedSosInActiveModerationList() { - when(sosNotifications.findByStatusInOrderByCreatedAtDesc(List.of("OPEN", "CONFIRMED"))).thenReturn(List.of()); - - service.listSosNotifications(); - - verify(sosNotifications).findByStatusInOrderByCreatedAtDesc(List.of("OPEN", "CONFIRMED")); - verify(sosNotifications, never()).findAllByOrderByCreatedAtDesc(); + verify(users).save(volunteer); } private VolunteerService.HelpRequestReq helpRequestReq() { return new VolunteerService.HelpRequestReq( - "Санкт-Петербург, Садовая улица, дом 12", - "Санкт-Петербург, Невский проспект, дом 1", - LocalDate.now().plusDays(1).format(DATE_FORMAT), + "Садовая улица, 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) - ) - ); + 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("Имя" + id) - .lastName("Фамилия" + id) + .firstName("Иван") + .lastName("Петров") .phoneHash(Crypto.sha256Hex(Crypto.normPhone(phone))) .role(role) - .passHash("hash") .active(true) .totalPoints(0) .build(); @@ -443,56 +300,24 @@ private VolunteerApplicationEntity application(Long id, UserEntity applicant, St application.setPhone("79990000001"); application.setSocialNickname("@volunteer"); application.setStatus(status); - application.setCreatedAt(Instant.now()); + application.setCreatedAt(Instant.parse("2026-05-01T10:00:00Z")); return application; } - private VolunteerUserStateEntity state(UserEntity user) { - VolunteerUserStateEntity state = new VolunteerUserStateEntity(); - state.setUser(user); - state.setUserId(user.getId()); - return state; - } - 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.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.setComment("Нужно помочь дойти до остановки"); request.setStatus(status); - request.setCreatedAt(Instant.now()); + request.setCreatedAt(Instant.parse("2026-05-01T10:00:00Z")); return request; } - - - private SosNotificationEntity sos(Long id, HelpRequestEntity request, UserEntity sender, String reason, String status) { - SosNotificationEntity sos = new SosNotificationEntity(); - sos.setId(id); - sos.setRequest(request); - sos.setSender(sender); - sos.setReason(reason); - sos.setStatus(status); - sos.setComment("Комментарий SOS"); - sos.setCreatedAt(Instant.now()); - return sos; - } - - private VolunteerComplaintEntity complaint(Long id, HelpRequestEntity request, UserEntity author, UserEntity target) { - VolunteerComplaintEntity complaint = new VolunteerComplaintEntity(); - complaint.setId(id); - complaint.setRequest(request); - complaint.setAuthor(author); - complaint.setTarget(target); - complaint.setText("Жалоба"); - complaint.setStatus("PENDING"); - complaint.setCreatedAt(Instant.now()); - return complaint; - } } From 17a317ef78db13f46c32550a765dbd8fc3928a30 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 15:58:09 +0300 Subject: [PATCH 15/16] chore: align test photo assets with upload script --- scripts/test-photos/{avatar.jpg => avatar.png} | Bin scripts/test-photos/curb.png | Bin 0 -> 73055 bytes scripts/test-photos/{gravel.jpg => gravel.png} | Bin .../test-photos/{potholes.jpg => potholes.png} | Bin scripts/test-photos/{stairs.jpg => stairs.png} | Bin 5 files changed, 0 insertions(+), 0 deletions(-) rename scripts/test-photos/{avatar.jpg => avatar.png} (100%) create mode 100644 scripts/test-photos/curb.png rename scripts/test-photos/{gravel.jpg => gravel.png} (100%) rename scripts/test-photos/{potholes.jpg => potholes.png} (100%) rename scripts/test-photos/{stairs.jpg => stairs.png} (100%) 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 0000000000000000000000000000000000000000..86b62b761fec0f569d2fc91d0e78704029700f2f GIT binary patch literal 73055 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�Uw(fo^Vl{?^tX#Kx3ijX8Jaw|?LI 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 OC|X-~I=xt~S^wGG^VOXI literal 0 HcmV?d00001 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 From 2f26d26cc6d7af9b529f9b06674ebf613e014953 Mon Sep 17 00:00:00 2001 From: SanriaArgos Date: Sun, 24 May 2026 15:58:40 +0300 Subject: [PATCH 16/16] docs: document test data and photo upload scripts --- RUN_TESTS.md | 5 +++++ 1 file changed, 5 insertions(+) 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`, если его еще нет.