From 5bc943f91e203bcfd01fa4d0ee8563f476ace0f9 Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Fri, 9 Jan 2026 15:59:22 -0500 Subject: [PATCH 1/6] Add test resource disable flag Signed-off-by: Jesse Elliott --- app/build.gradle | 3 + .../src/lib/services/Libre311/Libre311.ts | 149 +++++++++++++++--- 2 files changed, 127 insertions(+), 25 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ad259448..27f1ea6b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -91,6 +91,9 @@ micronaut { incremental(true) annotations("app.*") } + testResources { + enabled = System.getenv("MICRONAUT_TEST_RESOURCES_ENABLED") == "false" ? false : true + } } tasks.withType(JavaCompile) { diff --git a/frontend/src/lib/services/Libre311/Libre311.ts b/frontend/src/lib/services/Libre311/Libre311.ts index a3926756..0a5aa4bb 100644 --- a/frontend/src/lib/services/Libre311/Libre311.ts +++ b/frontend/src/lib/services/Libre311/Libre311.ts @@ -240,6 +240,64 @@ export type CreateServiceRequestParams = HasServiceCode & media_url?: string; }; +export const CreateRemovalSuggestionParamsSchema = z.object({ + service_request_id: z.number(), + email: z.string().email(), + name: z.string().optional(), + phone: z.string().optional(), + reason: z.string(), + g_recaptcha_response: z.string().optional() +}); + +export type CreateRemovalSuggestionParams = z.infer; + +export const ServiceRequestRemovalSuggestionSchema = z.object({ + id: z.number(), + service_request_id: z.number(), + email: z.string(), + name: z.string().optional(), + phone: z.string().optional(), + reason: z.string(), + date_created: z.string() +}); + +export type ServiceRequestRemovalSuggestion = z.infer; + +const PaginationSchema = z.object({ + size: z.number(), // the number of records per page + offset: z.number(), // if pageSize = 10 and pageNumber = 5 then offset = 50, + pageNumber: z.number(), // the current page number (first page starts at 0) + totalPages: z.number(), // the total number of pages + totalSize: z.number() // the total number of records +}); + +export type Pagination = z.infer; + +export const EMPTY_PAGINATION = { + size: 0, + offset: 0, + pageNumber: 0, + totalPages: 0, + totalSize: 0 +}; + +export type HasPagination = { + pagination: Pagination; +}; + +export type HasMetadata = { + metadata: T; +}; + +export const GetRemovalSuggestionsResponseSchema = z.object({ + content: z.array(ServiceRequestRemovalSuggestionSchema), + metadata: z.object({ + pagination: PaginationSchema + }) +}); + +export type GetRemovalSuggestionsResponse = z.infer; + export const LowServiceRequestPrioritySchema = z.literal('low'); export const MediumServiceRequestPrioritySchema = z.literal('medium'); export const HighServiceRequestPrioritySchema = z.literal('high'); @@ -445,31 +503,7 @@ const JurisdictionConfigSchema = z export type JurisdictionConfig = z.infer; -const PaginationSchema = z.object({ - size: z.number(), // the number of records per page - offset: z.number(), // if pageSize = 10 and pageNumber = 5 then offset = 50, - pageNumber: z.number(), // the current page number (first page starts at 0) - totalPages: z.number(), // the total number of pages - totalSize: z.number() // the total number of records -}); - -export type Pagination = z.infer; - -export const EMPTY_PAGINATION = { - size: 0, - offset: 0, - pageNumber: 0, - totalPages: 0, - totalSize: 0 -}; - -export type HasPagination = { - pagination: Pagination; -}; -export type HasMetadata = { - metadata: T; -}; export type ServiceRequestsResponse = { serviceRequests: GetServiceRequestsResponse; @@ -547,6 +581,10 @@ export interface Libre311Service extends Open311Service { params: UpdateSensitiveServiceRequestRequest ): Promise; deleteServiceRequest(params: DeleteServiceRequestRequest): Promise; + createRemovalSuggestion(params: CreateRemovalSuggestionParams): Promise; + getRemovalSuggestions( + params: HasPagination & HasJurisdictionId + ): Promise; } const Libre311ServicePropsSchema = z.object({ @@ -596,7 +634,11 @@ const ROUTES = { updateAttributesOrder: (params: UpdateAttributesOrderParams & HasJurisdictionId) => `/jurisdiction-admin/services/${params.service_code}/attributes-order?jurisdiction_id=${params.jurisdiction_id}`, deleteServiceRequest: (params: HasServiceRequestId & HasJurisdictionId) => - `/requests/${params.service_request_id}?jurisdiction_id=${params.jurisdiction_id}` + `/requests/${params.service_request_id}?jurisdiction_id=${params.jurisdiction_id}`, + postRemovalSuggestion: (params: HasServiceRequestId & HasJurisdictionId) => + `/requests/${params.service_request_id}/removal-suggestions?jurisdiction_id=${params.jurisdiction_id}`, + getRemovalSuggestions: (params: HasJurisdictionId) => + `/jurisdiction-admin/requests/removal-suggestions?jurisdiction_id=${params.jurisdiction_id}` }; export async function getJurisdictionConfig(baseURL: string): Promise { @@ -663,6 +705,63 @@ export class Libre311ServiceImpl implements Libre311Service { this.recaptchaService = props.recaptchaService; this.geocodingService = new GeocodingServiceImpl(); } + + async createRemovalSuggestion(params: CreateRemovalSuggestionParams): Promise { + CreateRemovalSuggestionParamsSchema.parse(params); + const paramsWithRecaptcha = await this.recaptchaService.wrapWithRecaptcha( + params, + 'create_removal_suggestion' + ); + try { + await this.axiosInstance.post( + ROUTES.postRemovalSuggestion({ ...params, jurisdiction_id: this.jurisdictionId }), + paramsWithRecaptcha + ); + } catch (error) { + console.log(error); + throw error; + } + } + + async getRemovalSuggestions( + params: HasPagination & HasJurisdictionId + ): Promise { + const res = await this.axiosInstance.get( + ROUTES.getRemovalSuggestions({ jurisdiction_id: this.jurisdictionId }), + { + params: { + page: params.pagination.pageNumber, + size: params.pagination.size + } + } + ); + // Note: The backend returns Page, so we need to map the response structure if it differs from what we expect. + // Micronaut Page serialization: content, pageable, totalPages, totalSize, etc. + // We can define a schema for this if we want runtime validation, but for now let's trust the type. + // However, we need to extract pagination headers if they are used, or use the body if it's in the body. + // In RootController.getServiceRequestsJson, it returns headers. + // In JurisdictionAdminController.getRemovalSuggestions, it returns Page object directly as JSON body. + + // Let's assume standard Micronaut Page JSON serialization which puts content in "content" and pagination info in "pageable" or similar fields. + // Wait, RootController returns List but adds headers for pagination. + // But JurisdictionAdminController returns Page. + // Micronaut's default JSON for Page includes "content", "pageable", "totalSize", "totalPages". + const data = res.data as any; + const response = { + content: data.content, + metadata: { + pagination: { + offset: data.pageable?.offset || 0, + pageNumber: data.pageable?.pageNumber || 0, + size: data.pageable?.pageSize || 0, + totalPages: data.totalPages || 0, + totalSize: data.totalSize || 0 + } + } + }; + return GetRemovalSuggestionsResponseSchema.parse(response); + } + async deleteServiceRequest(params: DeleteServiceRequestRequest): Promise { try { const res = await this.axiosInstance.delete( From ca91a79bbfbe115d14e334a30495ad51dfda8e7b Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Fri, 9 Jan 2026 16:19:36 -0500 Subject: [PATCH 2/6] Add removal suggestions to backend Signed-off-by: Jesse Elliott --- .../java/app/JurisdictionAdminController.java | 22 ++++ app/src/main/java/app/RootController.java | 13 ++ ...estServiceRequestRemovalSuggestionDTO.java | 75 +++++++++++ .../dto/servicerequest/ServiceRequestDTO.java | 11 ++ .../ServiceRequestRemovalSuggestionDTO.java | 86 ++++++++++++ .../model/servicerequest/ServiceRequest.java | 2 + .../ServiceRequestRemovalSuggestion.java | 123 ++++++++++++++++++ .../ServiceRequestRemovalSuggestionCount.java | 32 +++++ ...iceRequestRemovalSuggestionRepository.java | 24 ++++ .../ServiceRequestRepository.java | 26 +--- .../servicerequest/ServiceRequestService.java | 71 +++++++++- ...dd_service_request_removal_suggestions.sql | 15 +++ 12 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/app/dto/servicerequest/PostRequestServiceRequestRemovalSuggestionDTO.java create mode 100644 app/src/main/java/app/dto/servicerequest/ServiceRequestRemovalSuggestionDTO.java create mode 100644 app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestion.java create mode 100644 app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionCount.java create mode 100644 app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionRepository.java create mode 100644 app/src/main/resources/db/migration/V11__add_service_request_removal_suggestions.sql diff --git a/app/src/main/java/app/JurisdictionAdminController.java b/app/src/main/java/app/JurisdictionAdminController.java index 592d2aa1..103d9536 100644 --- a/app/src/main/java/app/JurisdictionAdminController.java +++ b/app/src/main/java/app/JurisdictionAdminController.java @@ -42,6 +42,11 @@ import java.net.MalformedURLException; import java.util.List; +import app.dto.servicerequest.ServiceRequestRemovalSuggestionDTO; +import app.model.servicerequest.ServiceRequestRemovalSuggestion; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; + import static app.security.Permission.*; @Controller("/api/jurisdiction-admin") @@ -56,6 +61,23 @@ public JurisdictionAdminController(ServiceService serviceService, ServiceRequest this.serviceRequestService = serviceRequestService; } + @Get(uris = { "/requests/removal-suggestions{?jurisdiction_id}", "/requests/removal-suggestions.json{?jurisdiction_id}" }) + @ExecuteOn(TaskExecutors.IO) + @RequiresPermissions({LIBRE311_ADMIN_VIEW_SYSTEM, LIBRE311_ADMIN_VIEW_TENANT, LIBRE311_ADMIN_VIEW_SUBTENANT}) + public Page getRemovalSuggestions(@Valid Pageable pageable, + @Nullable @QueryValue("jurisdiction_id") String jurisdiction_id) { + return serviceRequestService.getRemovalSuggestions(jurisdiction_id, pageable); + } + + @Delete(uris = { "/requests/removal-suggestions/{id}{?jurisdiction_id}", "/requests/removal-suggestions/{id}.json{?jurisdiction_id}" }) + @ExecuteOn(TaskExecutors.IO) + @RequiresPermissions({LIBRE311_ADMIN_EDIT_SYSTEM, LIBRE311_ADMIN_EDIT_TENANT, LIBRE311_ADMIN_EDIT_SUBTENANT}) + public HttpResponse deleteRemovalSuggestion(Long id, + @Nullable @QueryValue("jurisdiction_id") String jurisdiction_id) { + serviceRequestService.deleteRemovalSuggestion(id, jurisdiction_id); + return HttpResponse.ok(); + } + @Post(uris = { "/services{?jurisdiction_id}", "/services.json{?jurisdiction_id}" }) @ExecuteOn(TaskExecutors.IO) @RequiresPermissions({LIBRE311_ADMIN_EDIT_SYSTEM, LIBRE311_ADMIN_EDIT_TENANT, LIBRE311_ADMIN_EDIT_SUBTENANT}) diff --git a/app/src/main/java/app/RootController.java b/app/src/main/java/app/RootController.java index 58d4cc00..71df7b46 100644 --- a/app/src/main/java/app/RootController.java +++ b/app/src/main/java/app/RootController.java @@ -164,6 +164,19 @@ public String createServiceRequestXml(HttpRequest request, return xmlMapper.writeValueAsString(serviceRequestList); } + @Post(uris = {"/requests/{serviceRequestId}/removal-suggestions{?jurisdiction_id}", "/requests/{serviceRequestId}/removal-suggestions.json{?jurisdiction_id}"}) + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @ExecuteOn(TaskExecutors.IO) + public HttpResponse createServiceRequestRemovalSuggestion( + @PathVariable Long serviceRequestId, + @Valid @Body PostRequestServiceRequestRemovalSuggestionDTO requestDTO, + @Nullable @QueryValue("jurisdiction_id") String jurisdiction_id) { + + serviceRequestService.createRemovalSuggestion(serviceRequestId, jurisdiction_id, requestDTO); + return HttpResponse.ok(); + } + @Get(uris = {"/requests{?jurisdiction_id}", "/requests.json{?jurisdiction_id}"}) @Produces(MediaType.APPLICATION_JSON) @ExecuteOn(TaskExecutors.IO) diff --git a/app/src/main/java/app/dto/servicerequest/PostRequestServiceRequestRemovalSuggestionDTO.java b/app/src/main/java/app/dto/servicerequest/PostRequestServiceRequestRemovalSuggestionDTO.java new file mode 100644 index 00000000..7260486a --- /dev/null +++ b/app/src/main/java/app/dto/servicerequest/PostRequestServiceRequestRemovalSuggestionDTO.java @@ -0,0 +1,75 @@ +package app.dto.servicerequest; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Introspected +public class PostRequestServiceRequestRemovalSuggestionDTO { + + @NotNull + @Email(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$") + private String email; + + @Nullable + private String name; + + @Nullable + private String phone; + + @NotNull + @NotBlank + private String reason; + + @NotBlank + @JsonProperty("g_recaptcha_response") + private String gRecaptchaResponse; + + public PostRequestServiceRequestRemovalSuggestionDTO() { + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + @Nullable + public String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + @Nullable + public String getPhone() { + return phone; + } + + public void setPhone(@Nullable String phone) { + this.phone = phone; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getgRecaptchaResponse() { + return gRecaptchaResponse; + } + + public void setgRecaptchaResponse(String gRecaptchaResponse) { + this.gRecaptchaResponse = gRecaptchaResponse; + } +} diff --git a/app/src/main/java/app/dto/servicerequest/ServiceRequestDTO.java b/app/src/main/java/app/dto/servicerequest/ServiceRequestDTO.java index cf20adc1..cf42263e 100644 --- a/app/src/main/java/app/dto/servicerequest/ServiceRequestDTO.java +++ b/app/src/main/java/app/dto/servicerequest/ServiceRequestDTO.java @@ -89,6 +89,9 @@ public class ServiceRequestDTO implements ServiceRequestResponseDTO { @JsonProperty("selected_values") private List selectedValues; + @JsonProperty("removal_suggestions_count") + private Long removalSuggestionsCount = 0L; + public ServiceRequestDTO() { } @@ -273,4 +276,12 @@ public List getSelectedValues() { public void setSelectedValues(List selectedValues) { this.selectedValues = selectedValues; } + + public Long getRemovalSuggestionsCount() { + return removalSuggestionsCount; + } + + public void setRemovalSuggestionsCount(Long removalSuggestionsCount) { + this.removalSuggestionsCount = removalSuggestionsCount; + } } diff --git a/app/src/main/java/app/dto/servicerequest/ServiceRequestRemovalSuggestionDTO.java b/app/src/main/java/app/dto/servicerequest/ServiceRequestRemovalSuggestionDTO.java new file mode 100644 index 00000000..819a00fc --- /dev/null +++ b/app/src/main/java/app/dto/servicerequest/ServiceRequestRemovalSuggestionDTO.java @@ -0,0 +1,86 @@ +package app.dto.servicerequest; + +import app.model.servicerequest.ServiceRequest; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import java.time.Instant; + +@Introspected +public class ServiceRequestRemovalSuggestionDTO { + + private Long id; + @JsonProperty("service_request_id") + private Long serviceRequestId; + private String email; + @Nullable + private String name; + @Nullable + private String phone; + private String reason; + @JsonProperty("date_created") + @JsonFormat(shape = JsonFormat.Shape.STRING) + private Instant dateCreated; + + public ServiceRequestRemovalSuggestionDTO() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getServiceRequestId() { + return serviceRequestId; + } + + public void setServiceRequestId(Long serviceRequestId) { + this.serviceRequestId = serviceRequestId; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + @Nullable + public String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + @Nullable + public String getPhone() { + return phone; + } + + public void setPhone(@Nullable String phone) { + this.phone = phone; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public Instant getDateCreated() { + return dateCreated; + } + + public void setDateCreated(Instant dateCreated) { + this.dateCreated = dateCreated; + } +} diff --git a/app/src/main/java/app/model/servicerequest/ServiceRequest.java b/app/src/main/java/app/model/servicerequest/ServiceRequest.java index ca5cf2f6..12a5887d 100644 --- a/app/src/main/java/app/model/servicerequest/ServiceRequest.java +++ b/app/src/main/java/app/model/servicerequest/ServiceRequest.java @@ -20,6 +20,7 @@ import io.micronaut.data.annotation.DateCreated; import io.micronaut.data.annotation.DateUpdated; +import io.micronaut.data.annotation.Where; import jakarta.persistence.*; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; @@ -30,6 +31,7 @@ @Entity @Table(name = "service_requests") +@Where(value = "@.deleted = false") public class ServiceRequest { @Id diff --git a/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestion.java b/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestion.java new file mode 100644 index 00000000..b0f146f0 --- /dev/null +++ b/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestion.java @@ -0,0 +1,123 @@ +package app.model.servicerequest; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.annotation.DateCreated; +import io.micronaut.data.annotation.Where; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; + +@Entity +@Table(name = "service_request_removal_suggestions") +@Where(value = "@.deleted = false") +public class ServiceRequestRemovalSuggestion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(name = "service_request_id") + private Long serviceRequestId; + + @NotNull + @Column(name = "jurisdiction_id") + private String jurisdictionId; + + @NotNull + @Email(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$") + private String email; + + @Nullable + private String name; + + @Nullable + private String phone; + + @NotNull + @Column(columnDefinition = "TEXT") + private String reason; + + @DateCreated + private Instant dateCreated; + + private boolean deleted; + + public ServiceRequestRemovalSuggestion() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getServiceRequestId() { + return serviceRequestId; + } + + public void setServiceRequestId(Long serviceRequestId) { + this.serviceRequestId = serviceRequestId; + } + + public String getJurisdictionId() { + return jurisdictionId; + } + + public void setJurisdictionId(String jurisdictionId) { + this.jurisdictionId = jurisdictionId; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + @Nullable + public String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + @Nullable + public String getPhone() { + return phone; + } + + public void setPhone(@Nullable String phone) { + this.phone = phone; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public Instant getDateCreated() { + return dateCreated; + } + + public void setDateCreated(Instant dateCreated) { + this.dateCreated = dateCreated; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} \ No newline at end of file diff --git a/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionCount.java b/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionCount.java new file mode 100644 index 00000000..ee27e12c --- /dev/null +++ b/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionCount.java @@ -0,0 +1,32 @@ +package app.model.servicerequest; + +import io.micronaut.core.annotation.Introspected; + +@Introspected +public class ServiceRequestRemovalSuggestionCount { + private Long serviceRequestId; + private Long count; + + public ServiceRequestRemovalSuggestionCount() {} + + public ServiceRequestRemovalSuggestionCount(Long serviceRequestId, Long count) { + this.serviceRequestId = serviceRequestId; + this.count = count; + } + + public Long getServiceRequestId() { + return serviceRequestId; + } + + public void setServiceRequestId(Long serviceRequestId) { + this.serviceRequestId = serviceRequestId; + } + + public Long getCount() { + return count; + } + + public void setCount(Long count) { + this.count = count; + } +} diff --git a/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionRepository.java b/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionRepository.java new file mode 100644 index 00000000..0e9da665 --- /dev/null +++ b/app/src/main/java/app/model/servicerequest/ServiceRequestRemovalSuggestionRepository.java @@ -0,0 +1,24 @@ +package app.model.servicerequest; + +import app.dto.servicerequest.ServiceRequestRemovalSuggestionDTO; +import io.micronaut.data.annotation.Query; +import io.micronaut.data.annotation.Repository; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.data.repository.CrudRepository; +import java.util.List; +import java.util.Map; + +@Repository +public interface ServiceRequestRemovalSuggestionRepository extends CrudRepository { + Page findAllByJurisdictionId(String jurisdictionId, Pageable pageable); + + @Query("update ServiceRequestRemovalSuggestion s set s.deleted = true where s.serviceRequestId = :serviceRequestId and s.jurisdictionId = :jurisdictionId") + void deleteByServiceRequestIdAndJurisdictionId(Long serviceRequestId, String jurisdictionId); + + @Query("update ServiceRequestRemovalSuggestion s set s.deleted = true where s.id = :id and s.jurisdictionId = :jurisdictionId") + void softDelete(Long id, String jurisdictionId); + + @Query("SELECT s.serviceRequestId as serviceRequestId, COUNT(s) as count FROM ServiceRequestRemovalSuggestion s WHERE s.serviceRequestId IN (:serviceRequestIds) AND s.deleted = false GROUP BY s.serviceRequestId") + List countByServiceRequestIdIn(List serviceRequestIds); +} \ No newline at end of file diff --git a/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java b/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java index 614a6e56..b001f286 100644 --- a/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java +++ b/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java @@ -16,6 +16,7 @@ import app.model.jurisdiction.Jurisdiction_; import app.model.service.Service_; +import io.micronaut.data.annotation.Query; import io.micronaut.data.annotation.Repository; import io.micronaut.data.model.Page; import io.micronaut.data.model.Pageable; @@ -25,6 +26,7 @@ import io.micronaut.data.repository.jpa.JpaSpecificationExecutor; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import jakarta.transaction.Transactional; + import java.time.Instant; import java.util.List; import java.util.Optional; @@ -33,26 +35,12 @@ public interface ServiceRequestRepository extends PageableRepository, JpaSpecificationExecutor { - default Page findByIdInAndJurisdictionId(List serviceRequestIds, String jurisdictionId, - Pageable pageable) { - return findByIdInAndJurisdictionIdAndDeleted(serviceRequestIds, jurisdictionId, false, pageable); - } - - - default List findByIdInAndJurisdictionId(List serviceRequestIds, String jurisdictionId, Sort sort) { - return findByIdInAndJurisdictionIdAndDeleted(serviceRequestIds, jurisdictionId, false, sort); - } - - - default Optional findByIdAndJurisdictionId(Long serviceRequestId, String jurisdictionId) { - return findByIdAndJurisdictionIdAndDeleted(serviceRequestId, jurisdictionId, false); - } - - Page findByIdInAndJurisdictionIdAndDeleted(List serviceRequestIds, String jurisdictionId, boolean deleted, Pageable pageable); - List findByIdInAndJurisdictionIdAndDeleted(List serviceRequestIds, String jurisdictionId, boolean deleted, Sort sort); - Optional findByIdAndJurisdictionIdAndDeleted(Long serviceRequestId, String jurisdictionId, boolean deleted); + Page findByIdInAndJurisdictionId(List serviceRequestIds, String jurisdictionId, Pageable pageable); + List findByIdInAndJurisdictionId(List serviceRequestIds, String jurisdictionId, Sort sort); + Optional findByIdAndJurisdictionId(Long serviceRequestId, String jurisdictionId); - Integer updateDeletedByIdAndJurisdictionIdAndDeletedFalse(Long id, String jurisdictionId, boolean deleted); + @Query("update ServiceRequest sr set sr.deleted = true where sr.id = :id and sr.jurisdiction.id = :jurisdictionId") + Integer delete(Long id, String jurisdictionId); @Transactional default Page findAllBy(String jurisdictionId, List serviceCodes, diff --git a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java index 389d4f37..2d4f946e 100644 --- a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java +++ b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java @@ -28,6 +28,9 @@ import app.model.servicedefinition.ServiceDefinitionAttributeRepository; import app.model.servicerequest.ServiceRequest; import app.model.servicerequest.ServiceRequestPriority; +import app.model.servicerequest.ServiceRequestRemovalSuggestion; +import app.model.servicerequest.ServiceRequestRemovalSuggestionCount; +import app.model.servicerequest.ServiceRequestRemovalSuggestionRepository; import app.model.servicerequest.ServiceRequestRepository; import app.model.servicerequest.ServiceRequestStatus; import app.recaptcha.ReCaptchaService; @@ -85,6 +88,7 @@ public InvalidServiceRequestException(String message) { private static final Logger LOG = LoggerFactory.getLogger(ServiceRequestService.class); private final ServiceRequestRepository serviceRequestRepository; + private final ServiceRequestRemovalSuggestionRepository removalSuggestionRepository; private final ServiceRepository serviceRepository; private final ServiceDefinitionAttributeRepository attributeRepository; private final ReCaptchaService reCaptchaService; @@ -94,6 +98,7 @@ public InvalidServiceRequestException(String message) { LibreGeometryFactory libreGeometryFactory; public ServiceRequestService(ServiceRequestRepository serviceRequestRepository, + ServiceRequestRemovalSuggestionRepository removalSuggestionRepository, ServiceRepository serviceRepository, ServiceDefinitionAttributeRepository attributeRepository, ReCaptchaService reCaptchaService, StorageUrlUtil storageUrlUtil, @@ -101,6 +106,7 @@ public ServiceRequestService(ServiceRequestRepository serviceRequestRepository, JurisdictionBoundaryService jurisdictionBoundaryService, LibreGeometryFactory libreGeometryFactory) { this.serviceRequestRepository = serviceRequestRepository; + this.removalSuggestionRepository = removalSuggestionRepository; this.serviceRepository = serviceRepository; this.attributeRepository = attributeRepository; this.reCaptchaService = reCaptchaService; @@ -173,6 +179,41 @@ public PostResponseServiceRequestDTO createServiceRequest(HttpRequest request return new PostResponseServiceRequestDTO(serviceRequestRepository.save(serviceRequest)); } + public void createRemovalSuggestion(Long serviceRequestId, String jurisdictionId, PostRequestServiceRequestRemovalSuggestionDTO suggestionDTO) { + reCaptchaService.verifyReCaptcha(suggestionDTO.getgRecaptchaResponse()); + Optional serviceRequestOptional = serviceRequestRepository.findByIdAndJurisdictionId(serviceRequestId, jurisdictionId); + if (serviceRequestOptional.isEmpty()) { + throw new InvalidServiceRequestException("Service Request not found."); + } + + ServiceRequestRemovalSuggestion suggestion = new ServiceRequestRemovalSuggestion(); + suggestion.setServiceRequestId(serviceRequestOptional.get().getId()); + suggestion.setJurisdictionId(jurisdictionId); + suggestion.setEmail(suggestionDTO.getEmail()); + suggestion.setName(suggestionDTO.getName()); + suggestion.setPhone(suggestionDTO.getPhone()); + suggestion.setReason(suggestionDTO.getReason()); + + removalSuggestionRepository.save(suggestion); + } + + public Page getRemovalSuggestions(String jurisdictionId, Pageable pageable) { + return removalSuggestionRepository.findAllByJurisdictionId(jurisdictionId, pageable) + .map(this::convertToSuggestionDTO); + } + + private ServiceRequestRemovalSuggestionDTO convertToSuggestionDTO(ServiceRequestRemovalSuggestion suggestion) { + ServiceRequestRemovalSuggestionDTO dto = new ServiceRequestRemovalSuggestionDTO(); + dto.setId(suggestion.getId()); + dto.setServiceRequestId(suggestion.getServiceRequestId()); + dto.setEmail(suggestion.getEmail()); + dto.setName(suggestion.getName()); + dto.setPhone(suggestion.getPhone()); + dto.setReason(suggestion.getReason()); + dto.setDateCreated(suggestion.getDateCreated()); + return dto; + } + private boolean validMediaUrl(String mediaUrl) { if (mediaUrl == null) return true; return mediaUrl.startsWith(storageUrlUtil.getBucketUrlString()); @@ -403,7 +444,20 @@ public Page findAll(GetServiceRequestsDTO requestDTO, String : ServiceRequestService::convertToDTO; - return getServiceRequestPage(requestDTO, jurisdictionId).map(mapper); + Page page = getServiceRequestPage(requestDTO, jurisdictionId); + Page dtoPage = page.map(mapper); + + if (canViewSensitive && !dtoPage.getContent().isEmpty()) { + List ids = dtoPage.getContent().stream().map(ServiceRequestDTO::getId).collect(Collectors.toList()); + List counts = removalSuggestionRepository.countByServiceRequestIdIn(ids); + Map countMap = counts.stream().collect(Collectors.toMap(ServiceRequestRemovalSuggestionCount::getServiceRequestId, ServiceRequestRemovalSuggestionCount::getCount)); + + dtoPage.getContent().forEach(dto -> { + dto.setRemovalSuggestionsCount(countMap.getOrDefault(dto.getId(), 0L)); + }); + } + + return dtoPage; } private Page getServiceRequestPage(GetServiceRequestsDTO requestDTO, String jurisdictionId) { @@ -545,6 +599,19 @@ private List getServiceRequests(GetServiceRequestsDTO requestDTO } public int delete(Long serviceRequestId, String jurisdictionId) { - return serviceRequestRepository.updateDeletedByIdAndJurisdictionIdAndDeletedFalse(serviceRequestId, jurisdictionId, true); + removalSuggestionRepository.deleteByServiceRequestIdAndJurisdictionId(serviceRequestId, jurisdictionId); + return serviceRequestRepository.delete(serviceRequestId, jurisdictionId); + } + + public void deleteRemovalSuggestion(Long id, String jurisdictionId) { + Optional suggestionOptional = removalSuggestionRepository.findById(id); + if (suggestionOptional.isPresent()) { + ServiceRequestRemovalSuggestion suggestion = suggestionOptional.get(); + if (suggestion.getJurisdictionId().equals(jurisdictionId)) { + removalSuggestionRepository.softDelete(id, jurisdictionId); + return; + } + } + throw new InvalidServiceRequestException("Removal suggestion not found."); } } diff --git a/app/src/main/resources/db/migration/V11__add_service_request_removal_suggestions.sql b/app/src/main/resources/db/migration/V11__add_service_request_removal_suggestions.sql new file mode 100644 index 00000000..9caae225 --- /dev/null +++ b/app/src/main/resources/db/migration/V11__add_service_request_removal_suggestions.sql @@ -0,0 +1,15 @@ +CREATE TABLE service_request_removal_suggestions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + service_request_id BIGINT NOT NULL, + jurisdiction_id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + name VARCHAR(255), + phone VARCHAR(255), + reason TEXT NOT NULL, + date_created TIMESTAMP NOT NULL, + deleted BOOL NOT NULL, + CONSTRAINT fk_service_request_removal_suggestions_service_request + FOREIGN KEY (service_request_id) + REFERENCES service_requests (id) + ON DELETE CASCADE +); From ed2017cce84eb6591d54a2ea3314fc5fbfb281a8 Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Fri, 9 Jan 2026 16:19:55 -0500 Subject: [PATCH 3/6] Add removal suggestions to frontend Signed-off-by: Jesse Elliott --- .../lib/components/ConfirmationModal.svelte | 29 ++ .../components/RemovalSuggestionsList.svelte | 115 ++++++ .../components/RemovalSuggestionsTable.svelte | 150 ++++++++ .../src/lib/components/ServiceRequest.svelte | 30 +- .../components/ServiceRequestDetails.svelte | 13 +- .../src/lib/components/SuggestionModal.svelte | 110 ++++++ .../src/lib/context/ServiceRequestsContext.ts | 17 +- .../src/lib/services/Libre311/Libre311.ts | 21 +- .../src/routes/issues/table/+layout.svelte | 338 +++++++++--------- frontend/src/routes/issues/table/table.ts | 24 +- 10 files changed, 663 insertions(+), 184 deletions(-) create mode 100644 frontend/src/lib/components/ConfirmationModal.svelte create mode 100644 frontend/src/lib/components/RemovalSuggestionsList.svelte create mode 100644 frontend/src/lib/components/RemovalSuggestionsTable.svelte create mode 100644 frontend/src/lib/components/SuggestionModal.svelte diff --git a/frontend/src/lib/components/ConfirmationModal.svelte b/frontend/src/lib/components/ConfirmationModal.svelte new file mode 100644 index 00000000..4050f15c --- /dev/null +++ b/frontend/src/lib/components/ConfirmationModal.svelte @@ -0,0 +1,29 @@ + + +{#if open} + + + + {title} + +

{message}

+
+ +
+ + +
+
+
+
+
+{/if} diff --git a/frontend/src/lib/components/RemovalSuggestionsList.svelte b/frontend/src/lib/components/RemovalSuggestionsList.svelte new file mode 100644 index 00000000..bff2b6c0 --- /dev/null +++ b/frontend/src/lib/components/RemovalSuggestionsList.svelte @@ -0,0 +1,115 @@ + + +{#if suggestions.length > 0} + +
+

Removal Suggestions

+ + {#each suggestions as suggestion} + +
+
+ {suggestion.email} + {toTimeStamp(suggestion.date_created)} +
+ {#if suggestion.name} + {suggestion.name} + {/if} + {#if suggestion.phone} + {suggestion.phone} + {/if} +

{suggestion.reason}

+
+ +
+
+
+ {/each} +
+
+
+ + (showConfirmationModal = false)} + handleConfirm={confirmDismiss} + loading={isDeleting} + /> +{/if} diff --git a/frontend/src/lib/components/RemovalSuggestionsTable.svelte b/frontend/src/lib/components/RemovalSuggestionsTable.svelte new file mode 100644 index 00000000..3e97b689 --- /dev/null +++ b/frontend/src/lib/components/RemovalSuggestionsTable.svelte @@ -0,0 +1,150 @@ + + +
+ + + + {#each suggestions as suggestion} + selectRow(suggestion.service_request_id)}> + + {suggestion.id} + + + {suggestion.service_request_id} + + +
+ {suggestion.reason} +
+
+ +
+ {suggestion.email} + {#if suggestion.name}{suggestion.name}{/if} + {#if suggestion.phone}{suggestion.phone}{/if} +
+
+ + {toTimeStamp(suggestion.date_created)} + + + + +
+ {/each} + {#if suggestions.length === 0 && !loading} + + No removal suggestions found. + + + + + + + {/if} +
+ +
+ +
+
+
+
+ + diff --git a/frontend/src/lib/components/ServiceRequest.svelte b/frontend/src/lib/components/ServiceRequest.svelte index a1e2ea2b..d659b539 100644 --- a/frontend/src/lib/components/ServiceRequest.svelte +++ b/frontend/src/lib/components/ServiceRequest.svelte @@ -13,6 +13,8 @@ import ServiceRequestButtonsContainer from './ServiceRequestButtonsContainer.svelte'; import ServiceRequestStatusBadge from './ServiceRequestStatusBadge.svelte'; import AuthGuard from './AuthGuard.svelte'; + import ConfirmationModal from './ConfirmationModal.svelte'; + import RemovalSuggestionsList from './RemovalSuggestionsList.svelte'; const libre311 = useLibre311Service(); const alertError = useLibre311Context().alertError; @@ -23,6 +25,8 @@ export let back: string; let isUpdateButtonClicked: boolean = false; + let showDeleteModal = false; + let isDeleting = false; $: if ($page.url) { isUpdateButtonClicked = false; @@ -35,9 +39,12 @@ return `${serviceRequest.first_name ?? ''} ${serviceRequest.last_name ?? ''}`; } - async function deleteServiceReq() { - let confirmed = window.confirm('Are you sure you would like to delete this request?'); - if (!confirmed) return; + function deleteServiceReq() { + showDeleteModal = true; + } + + async function confirmDelete() { + isDeleting = true; try { await libre311.deleteServiceRequest({ service_request_id: serviceRequest.service_request_id @@ -51,6 +58,9 @@ goto('/issues/table'); } catch (error) { alertError(error); + } finally { + isDeleting = false; + showDeleteModal = false; } } @@ -77,8 +87,8 @@ } -
- +
+
@@ -234,8 +244,18 @@ {/if}
+
+ (showDeleteModal = false)} + handleConfirm={confirmDelete} + loading={isDeleting} +/> +