From f08e551edd7efe3c777929fa005d9dedf84e68ac Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Thu, 28 May 2026 11:23:08 -0400 Subject: [PATCH 01/12] Add photo voice specific backend code Signed-off-by: Jesse Elliott --- app/docker-compose.deps.yml | 15 ++++++--------- .../app/dto/jurisdiction/JurisdictionDTO.java | 12 ++++++++++++ .../dto/jurisdiction/PatchJurisdictionDTO.java | 11 +++++++++++ .../app/model/jurisdiction/Jurisdiction.java | 11 +++++++++++ .../servicerequest/ServiceRequestRepository.java | 16 ++++++++++++++++ .../jurisdiction/JurisdictionService.java | 5 +++++ .../servicerequest/ServiceRequestService.java | 13 ++++++++++--- .../V23__add_system_reserved_service_group.sql | 3 +++ .../V24__add_photo_voice_service_code.sql | 1 + ...change_photo_voice_service_code_to_bigint.sql | 1 + 10 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql create mode 100644 app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql create mode 100644 app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql diff --git a/app/docker-compose.deps.yml b/app/docker-compose.deps.yml index 64c1df47..1fb11adb 100644 --- a/app/docker-compose.deps.yml +++ b/app/docker-compose.deps.yml @@ -4,16 +4,18 @@ services: image: mysql:8.0 # NOTE: use of "mysql_native_password" is not recommended: https://dev.mysql.com/doc/refman/8.0/en/upgrading-from-previous-series.html#upgrade-caching-sha2-password # (this is just an example, not intended to be a production configuration) - command: --default-authentication-plugin=mysql_native_password + entrypoint: + - /bin/bash + - -c + - | + printf 'CREATE DATABASE IF NOT EXISTS unity_auth;\n' > /tmp/init.sql + exec docker-entrypoint.sh mysqld --default-authentication-plugin=mysql_native_password --init-file=/tmp/init.sql restart: always networks: - unity-network environment: MYSQL_ROOT_PASSWORD: test MYSQL_DATABASE: libre311 - configs: - - source: mysql-init - target: /docker-entrypoint-initdb.d/01-create-databases.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ptest"] @@ -88,11 +90,6 @@ services: # After writing the file, start Nginx nginx -g 'daemon off;' -configs: - mysql-init: - content: | - CREATE DATABASE IF NOT EXISTS unity_auth; - networks: default: name: unity-network diff --git a/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java b/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java index cbef5047..6f8e0936 100644 --- a/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java +++ b/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java @@ -63,6 +63,9 @@ public class JurisdictionDTO { @JsonProperty("project_feature") private app.model.jurisdiction.ProjectFeature projectFeature; + @JsonProperty("photo_voice_service_code") + private Long photoVoiceServiceCode; + @JsonProperty("closed_request_days_visible_user") private Integer closedRequestDaysVisibleUser; @@ -92,6 +95,7 @@ public JurisdictionDTO(Jurisdiction jurisdiction) { this.projectFeature = jurisdiction.getProjectFeature(); this.closedRequestDaysVisibleUser = jurisdiction.getClosedRequestDaysVisibleUser(); this.closedRequestDaysVisibleAdmin = jurisdiction.getClosedRequestDaysVisibleAdmin(); + this.photoVoiceServiceCode = jurisdiction.getPhotoVoiceServiceCode(); } public JurisdictionDTO(Jurisdiction jurisdiction, JurisdictionBoundary boundary) { @@ -223,4 +227,12 @@ public Integer getClosedRequestDaysVisibleAdmin() { public void setClosedRequestDaysVisibleAdmin(Integer closedRequestDaysVisibleAdmin) { this.closedRequestDaysVisibleAdmin = closedRequestDaysVisibleAdmin; } + + public Long getPhotoVoiceServiceCode() { + return photoVoiceServiceCode; + } + + public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { + this.photoVoiceServiceCode = photoVoiceServiceCode; + } } diff --git a/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java b/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java index 5dcd5204..0d0e1053 100644 --- a/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java +++ b/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java @@ -58,6 +58,9 @@ public class PatchJurisdictionDTO { @JsonProperty("project_feature") private app.model.jurisdiction.ProjectFeature projectFeature; + @JsonProperty("photo_voice_service_code") + private Long photoVoiceServiceCode; + @JsonProperty("closed_request_days_visible_user") private Integer closedRequestDaysVisibleUser; @@ -154,4 +157,12 @@ public Integer getClosedRequestDaysVisibleAdmin() { public void setClosedRequestDaysVisibleAdmin(Integer closedRequestDaysVisibleAdmin) { this.closedRequestDaysVisibleAdmin = closedRequestDaysVisibleAdmin; } + + public Long getPhotoVoiceServiceCode() { + return photoVoiceServiceCode; + } + + public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { + this.photoVoiceServiceCode = photoVoiceServiceCode; + } } diff --git a/app/src/main/java/app/model/jurisdiction/Jurisdiction.java b/app/src/main/java/app/model/jurisdiction/Jurisdiction.java index 0350fe60..98bf420d 100644 --- a/app/src/main/java/app/model/jurisdiction/Jurisdiction.java +++ b/app/src/main/java/app/model/jurisdiction/Jurisdiction.java @@ -71,6 +71,9 @@ public class Jurisdiction { @NotNull private ProjectFeature projectFeature = ProjectFeature.DISABLED; + @Nullable + private Long photoVoiceServiceCode; + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true, mappedBy = "jurisdiction") @OnDelete(action = OnDeleteAction.CASCADE) private Set remoteHosts = new HashSet<>(); @@ -216,4 +219,12 @@ public void setProjectFeature(ProjectFeature projectFeature) { this.projectFeature = projectFeature; } + public Long getPhotoVoiceServiceCode() { + return photoVoiceServiceCode; + } + + public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { + this.photoVoiceServiceCode = photoVoiceServiceCode; + } + } \ 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 1bf348ff..68b92f44 100644 --- a/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java +++ b/app/src/main/java/app/model/servicerequest/ServiceRequestRepository.java @@ -68,6 +68,18 @@ default Page findAllBy(String jurisdictionId, List service return findAll(specification, pageable); } + @Transactional + default Page findAllByNullProject(String jurisdictionId, List serviceCodes, + List status, List priority, + Instant startDate, Instant endDate, Instant closedRequestCutoffDate, Pageable pageable) { + + QuerySpecification specification = getServiceRequestSpecification(jurisdictionId, serviceCodes, + status, priority, startDate, endDate, null, closedRequestCutoffDate) + .and(Specifications.projectIsNull()); + + return findAll(specification, pageable); + } + @Transactional default List findAllBy(String jurisdictionId, List serviceCodes, List status, List priority, @@ -174,6 +186,10 @@ public static QuerySpecification projectIdEqual(Long projectId) return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get(ServiceRequest_.project).get("id"), projectId); } + public static QuerySpecification projectIsNull() { + return (root, query, criteriaBuilder) -> criteriaBuilder.isNull(root.get(ServiceRequest_.project)); + } + /** * Filter that applies date restriction only to CLOSED requests. * Non-closed requests are always visible; closed requests must be created after the cutoff date. diff --git a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java index 9b25f352..7ccd2425 100644 --- a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java +++ b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java @@ -188,6 +188,11 @@ private void applyPatch(PatchJurisdictionDTO jurisdictionDTO, Jurisdiction juris if (jurisdictionDTO.getClosedRequestDaysVisibleAdmin() != null) { jurisdiction.setClosedRequestDaysVisibleAdmin(jurisdictionDTO.getClosedRequestDaysVisibleAdmin()); } + if (jurisdictionDTO.getPhotoVoiceServiceCode() != null) { + jurisdiction.setPhotoVoiceServiceCode( + jurisdictionDTO.getPhotoVoiceServiceCode().equals(0L) ? null : jurisdictionDTO.getPhotoVoiceServiceCode() + ); + } } public JurisdictionDTO setJurisdictionRemoteHosts(String jurisdictionId, Set remoteHosts) { diff --git a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java index c15c5d90..3e958028 100644 --- a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java +++ b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java @@ -196,7 +196,8 @@ public PostResponseServiceRequestDTO createServiceRequest(HttpRequest request Optional project = projectService.findProjectForLocationAndTime(serviceRequest.getLocation(), Instant.now(), jurisdictionId); if (project.isPresent()) { serviceRequest.setProject(project.get()); - } else if (jurisdiction.getProjectFeature() == ProjectFeature.REQUIRED) { + } else if (jurisdiction.getProjectFeature() == ProjectFeature.REQUIRED + && !service.getId().equals(jurisdiction.getPhotoVoiceServiceCode())) { throw new InvalidServiceRequestException("The service request does not fall within any active project boundaries, and a project is required for this jurisdiction."); } } @@ -521,11 +522,13 @@ public Page findAll(GetServiceRequestsDTO requestDTO, String // Get the visibility days from jurisdiction config Jurisdiction jurisdiction = jurisdictionRepository.findByJurisdictionId(jurisdictionId); + int closedRequestDaysVisible = canViewSensitive ? jurisdiction.getClosedRequestDaysVisibleAdmin() : jurisdiction.getClosedRequestDaysVisibleUser(); - Page page = getServiceRequestPage(requestDTO, jurisdictionId, closedRequestDaysVisible); + boolean onlyNullProject = jurisdiction.getProjectFeature() == ProjectFeature.REQUIRED && requestDTO.getProjectId() == null; + Page page = getServiceRequestPage(requestDTO, jurisdictionId, closedRequestDaysVisible, onlyNullProject); Page dtoPage = page.map(mapper); if (canViewSensitive && !dtoPage.getContent().isEmpty()) { @@ -541,7 +544,7 @@ public Page findAll(GetServiceRequestsDTO requestDTO, String return dtoPage; } - private Page getServiceRequestPage(GetServiceRequestsDTO requestDTO, String jurisdictionId, int closedRequestDaysVisible) { + private Page getServiceRequestPage(GetServiceRequestsDTO requestDTO, String jurisdictionId, int closedRequestDaysVisible, boolean onlyNullProject) { String serviceRequestIds = requestDTO.getId(); List serviceCodes = requestDTO.getServiceCodes(); List statuses = requestDTO.getStatuses(); @@ -563,6 +566,10 @@ private Page getServiceRequestPage(GetServiceRequestsDTO request // Calculate the cutoff date for closed requests visibility Instant closedRequestCutoffDate = Instant.now().minus(closedRequestDaysVisible, ChronoUnit.DAYS); + if (onlyNullProject) { + return serviceRequestRepository.findAllByNullProject(jurisdictionId, serviceCodes, statuses, priorities, startDate, endDate, closedRequestCutoffDate, pageable); + } + return serviceRequestRepository.findAllBy(jurisdictionId, serviceCodes, statuses, priorities, startDate, endDate, projectId, closedRequestCutoffDate, pageable); } diff --git a/app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql b/app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql new file mode 100644 index 00000000..9885c6fb --- /dev/null +++ b/app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql @@ -0,0 +1,3 @@ +INSERT INTO service_groups (name, jurisdiction_id) + SELECT 'System Reserved', id + FROM jurisdictions; diff --git a/app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql b/app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql new file mode 100644 index 00000000..923d3d6e --- /dev/null +++ b/app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN photo_voice_service_code VARCHAR(255) NULL; diff --git a/app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql b/app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql new file mode 100644 index 00000000..56676067 --- /dev/null +++ b/app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions MODIFY COLUMN photo_voice_service_code BIGINT NULL; From bd0075c3773c23591a8acb8aa5fe2d8cd7bd22e0 Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Thu, 28 May 2026 11:24:23 -0400 Subject: [PATCH 02/12] Add specific frontend handling for Photo Voice Signed-off-by: Jesse Elliott --- .../PhotoVoiceDetailsForm.svelte | 113 +++++++ .../ReviewServiceRequest.svelte | 8 +- .../SelectARequestCategory.svelte | 10 +- .../CreateServiceRequestButton.svelte | 30 +- frontend/src/lib/constants/photoVoice.ts | 2 + .../src/lib/services/Libre311/Libre311.ts | 2 + frontend/src/lib/services/LinkResolver.ts | 4 +- frontend/src/lib/services/RecaptchaService.ts | 14 +- frontend/src/media/messages.json | 5 + frontend/src/routes/admin/system/+page.svelte | 310 +++++++++++++++++- .../src/routes/groups/config/+page.svelte | 13 +- frontend/src/routes/issue/create/+page.svelte | 1 + .../routes/photo-voice/create/+page.svelte | 213 ++++++++++++ 13 files changed, 693 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte create mode 100644 frontend/src/lib/constants/photoVoice.ts create mode 100644 frontend/src/routes/photo-voice/create/+page.svelte diff --git a/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte b/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte new file mode 100644 index 00000000..331958b6 --- /dev/null +++ b/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte @@ -0,0 +1,113 @@ + + +
+
+ {#if imageData} +
+ preview +
+ {/if} + + {#if loading} +

Loading...

+ {:else if questionAttribute} + {#if service.description} +

{service.description}

+ {/if} + + + + {#if answerError} +

{answerError}

+ {/if} + {:else} +

+ This feature is not fully configured. Contact an administrator. +

+ {/if} +
+ + + Confirm Details + +
+ + diff --git a/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte b/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte index f347e526..7cdff9dc 100644 --- a/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte +++ b/frontend/src/lib/components/CreateServiceRequest/ReviewServiceRequest.svelte @@ -23,6 +23,8 @@ const dispatch = createEventDispatcher<{ submitted: void }>(); export let params: CreateServiceRequestUIParams; + export let title: string = messages['reviewServiceRequest']['title']; + export let submitLabel: string = messages['reviewServiceRequest']['button_submit']; let imageData: string | undefined; let submittingServiceRequest: boolean = false; @@ -100,7 +102,7 @@
-

{messages['reviewServiceRequest']['title']}

+

{title}

@@ -162,9 +164,7 @@ - {messages['reviewServiceRequest']['button_submit']} + {submitLabel}
diff --git a/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte b/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte index 48795e41..716de376 100644 --- a/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte +++ b/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte @@ -18,6 +18,7 @@ import type { CreateServiceRequestUIParams } from './shared'; import messages from '$media/messages.json'; import { setUpAlertRole } from '$lib/utils/functions'; + import { SYSTEM_RESERVED_GROUP_NAME } from '$lib/constants/photoVoice'; export let params: Partial; @@ -36,10 +37,11 @@ onMount(fetchServiceList); function fetchServiceList() { - libre311 - .getServiceList() - .then((res) => { - serviceList = asAsyncSuccess(res); + Promise.all([libre311.getServiceList(), libre311.getGroupList()]) + .then(([services, groups]) => { + const reservedId = groups.find((g) => g.name === SYSTEM_RESERVED_GROUP_NAME)?.id; + const filtered = services.filter((s) => s.group_id !== reservedId); + serviceList = asAsyncSuccess(filtered); }) .catch((err) => (serviceList = asAsyncFailure(err))); } diff --git a/frontend/src/lib/components/CreateServiceRequestButton.svelte b/frontend/src/lib/components/CreateServiceRequestButton.svelte index a646dffe..612814c4 100644 --- a/frontend/src/lib/components/CreateServiceRequestButton.svelte +++ b/frontend/src/lib/components/CreateServiceRequestButton.svelte @@ -2,17 +2,31 @@ import { Button } from 'stwui'; import PlusCircleIcon from './Svg/outline/PlusCircleIcon.svelte'; import type { Maybe } from '$lib/utils/types'; + import { useJurisdiction } from '$lib/context/JurisdictionContext'; export let projectSlug: Maybe = undefined; + const jurisdictionStore = useJurisdiction(); + $: href = projectSlug ? `/issue/create?project_slug=${projectSlug}` : '/issue/create'; + $: showNewRequest = !($jurisdictionStore.project_feature === 'REQUIRED' && !projectSlug); + $: showLogPoint = !!$jurisdictionStore.photo_voice_service_code && !projectSlug; -
- -
+{#if showNewRequest || showLogPoint} +
+ {#if showNewRequest} + + {/if} + {#if showLogPoint} + + {/if} +
+{/if} diff --git a/frontend/src/lib/constants/photoVoice.ts b/frontend/src/lib/constants/photoVoice.ts new file mode 100644 index 00000000..8c40a468 --- /dev/null +++ b/frontend/src/lib/constants/photoVoice.ts @@ -0,0 +1,2 @@ +export const PHOTO_VOICE_SERVICE_NAME = 'Photo Voice'; +export const SYSTEM_RESERVED_GROUP_NAME = 'System Reserved'; diff --git a/frontend/src/lib/services/Libre311/Libre311.ts b/frontend/src/lib/services/Libre311/Libre311.ts index bc96d808..7533f9db 100644 --- a/frontend/src/lib/services/Libre311/Libre311.ts +++ b/frontend/src/lib/services/Libre311/Libre311.ts @@ -550,6 +550,7 @@ export type UpdateJurisdictionParams = { terms_of_use_content?: string; privacy_policy_content?: string; project_feature?: ProjectFeature; + photo_voice_service_code?: number; }; const JurisdictionConfigSchema = z @@ -561,6 +562,7 @@ const JurisdictionConfigSchema = z primary_color: z.string().optional(), primary_hover_color: z.string().optional(), project_feature: ProjectFeatureSchema.optional().default('DISABLED'), + photo_voice_service_code: z.number().optional().nullable(), tenant_id: z.number() }) .merge(HasJurisdictionIdSchema); diff --git a/frontend/src/lib/services/LinkResolver.ts b/frontend/src/lib/services/LinkResolver.ts index f5cd5d37..f489639b 100644 --- a/frontend/src/lib/services/LinkResolver.ts +++ b/frontend/src/lib/services/LinkResolver.ts @@ -142,12 +142,12 @@ export class LinkResolver { let currentStep = this.createIssuePageGetCurrentStep(url); const searchParams = this.copySearchParams(url.searchParams); searchParams.set('step', (++currentStep).toString()); - return `/issue/create?${searchParams.toString()}`; + return `${url.pathname}?${searchParams.toString()}`; } createIssuePagePrevious(url: URL) { let currentStep = this.createIssuePageGetCurrentStep(url); const searchParams = this.copySearchParams(url.searchParams); searchParams.set('step', (--currentStep).toString()); - return `/issue/create?${searchParams.toString()}`; + return `${url.pathname}?${searchParams.toString()}`; } } diff --git a/frontend/src/lib/services/RecaptchaService.ts b/frontend/src/lib/services/RecaptchaService.ts index e3e70603..17129d3a 100644 --- a/frontend/src/lib/services/RecaptchaService.ts +++ b/frontend/src/lib/services/RecaptchaService.ts @@ -46,7 +46,9 @@ export function recaptchaServiceFactory( mode: Mode, props: RecaptchaServiceProps ): RecaptchaService { - return mode === 'test' ? new MockRecaptchaService() : new RecaptchaServiceImpl(props); + return mode === 'test' || mode === 'development' + ? new MockRecaptchaService() + : new RecaptchaServiceImpl(props); } export async function loadRecaptchaProps(mode: Mode): Promise { @@ -55,13 +57,9 @@ export async function loadRecaptchaProps(mode: Mode): Promise('/recaptcha/recaptcha-key'); - recaptchaKey = res.data; - } else if (mode == 'development') { - throw new Error('VITE_GOOGLE_RECAPTCHA_KEY env variable must be set'); - } + if (!recaptchaKey && mode == 'production') { + const res = await axios.get('/recaptcha/recaptcha-key'); + recaptchaKey = res.data; } return { recaptchaKey }; diff --git a/frontend/src/media/messages.json b/frontend/src/media/messages.json index aebc1683..bddaf9c1 100644 --- a/frontend/src/media/messages.json +++ b/frontend/src/media/messages.json @@ -79,6 +79,11 @@ "back": "Back" } }, + "photoVoice": { + "create": "Submit Experience", + "review_title": "Experience Review", + "button_submit": "Submit Experience" + }, "reviewServiceRequest": { "title": "Service Request Review", "button_edit": "Edit", diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index 10271578..0692fd20 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -1,9 +1,11 @@ + + +
+
+

{messages['photoVoice']['create']}

+ {#if serviceLoading} +

Loading...

+ {:else if !photoVoiceService} +

+ Photo Voice is not currently enabled. An administrator must enable it from System + Administration. +

+ {:else if step === CreateServiceRequestSteps.LOCATION} + + {:else if step === CreateServiceRequestSteps.DETAILS} + + {:else if step === CreateServiceRequestSteps.REVIEW} + {#if isPhotoVoiceUIParams(params)} + {}} + /> + {:else} +

+ Something went wrong. . +

+ {/if} + {:else} + + {/if} +
+
+
+ + + + {#if step === CreateServiceRequestSteps.LOCATION && $isOnline} + + {/if} + + +
+ + +
+
+
+
From 913b354f12017684a572019295746037a5da2dbf Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Thu, 28 May 2026 17:04:54 -0400 Subject: [PATCH 03/12] Add link display, make links unique Signed-off-by: Jesse Elliott --- .../main/java/app/model/project/Project.java | 2 +- .../app/service/project/ProjectService.java | 11 ++++++++- ...6__promote_project_slug_to_real_column.sql | 23 +++++++++++++++++++ .../[project_id]/(view)/+layout.svelte | 12 ++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql diff --git a/app/src/main/java/app/model/project/Project.java b/app/src/main/java/app/model/project/Project.java index b8d2d032..4b167b43 100644 --- a/app/src/main/java/app/model/project/Project.java +++ b/app/src/main/java/app/model/project/Project.java @@ -35,7 +35,7 @@ public class Project { @NotNull private String name; - @Column(insertable = false, updatable = false) + @Column(updatable = false) private String slug; @Nullable diff --git a/app/src/main/java/app/service/project/ProjectService.java b/app/src/main/java/app/service/project/ProjectService.java index ac2e10ad..0bf56893 100644 --- a/app/src/main/java/app/service/project/ProjectService.java +++ b/app/src/main/java/app/service/project/ProjectService.java @@ -95,7 +95,16 @@ public ProjectDTO createProject(CreateProjectDTO dto, String jurisdictionId) { project.setEndDate(dto.getEndDate()); project.setJurisdiction(jurisdiction); - return new ProjectDTO(projectRepository.save(project)); + project = projectRepository.save(project); + project.setSlug(generateSlug(project.getName(), project.getId())); + return new ProjectDTO(projectRepository.update(project)); + } + + private static String generateSlug(String name, Long id) { + String base = name.toLowerCase() + .replaceAll("[^a-z0-9\\s]", "") + .replaceAll("\\s+", "-"); + return id + "-" + base; } @Transactional diff --git a/app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql b/app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql new file mode 100644 index 00000000..c9f8e392 --- /dev/null +++ b/app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql @@ -0,0 +1,23 @@ +-- Add standalone index so the jurisdiction_id FK retains index support +-- when we drop the compound index from V17 +ALTER TABLE projects ADD INDEX idx_projects_jurisdiction_id (jurisdiction_id); + +-- Drop the V17 compound index, then the virtual column +DROP INDEX idx_projects_jurisdiction_slug ON projects; +ALTER TABLE projects DROP COLUMN slug; + +-- Add a real slug column, populated from name + id for uniqueness and stability +ALTER TABLE projects ADD COLUMN slug VARCHAR(255); + +UPDATE projects SET slug = CONCAT( + id, + '-', + LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9 ]', ''), ' +', '-')) +); + +ALTER TABLE projects MODIFY COLUMN slug VARCHAR(255) NOT NULL; + +-- uk_projects_jurisdiction_slug covers jurisdiction_id as its leftmost prefix, +-- so the temporary standalone index is no longer needed +ALTER TABLE projects ADD UNIQUE KEY uk_projects_jurisdiction_slug (jurisdiction_id, slug); +DROP INDEX idx_projects_jurisdiction_id ON projects; diff --git a/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte b/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte index 5cdabf98..02cbcace 100644 --- a/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte +++ b/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte @@ -185,6 +185,18 @@ {project.status} + {#if project.slug} + URL: + + {$page.url.origin}/issues/map/project/{project.slug} + + + {/if}
{/if} From 637ecf230898afadc3fe758f698909c0fb337b8c Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Tue, 2 Jun 2026 14:44:28 -0400 Subject: [PATCH 04/12] Add additional admin functionality Signed-off-by: Jesse Elliott --- .../app/dto/jurisdiction/JurisdictionDTO.java | 24 +++++++++ .../jurisdiction/PatchJurisdictionDTO.java | 22 +++++++++ .../app/model/jurisdiction/Jurisdiction.java | 20 ++++++++ .../app/model/project/ProjectRepository.java | 3 ++ .../SystemReservedGroupInitializer.java | 49 +++++++++++++++++++ .../jurisdiction/JurisdictionService.java | 8 +++ .../app/service/project/ProjectService.java | 10 ++-- .../servicerequest/ServiceRequestService.java | 6 +-- .../V23__add_photo_voice_service_code.sql | 1 + ...V23__add_system_reserved_service_group.sql | 3 -- .../V24__add_photo_voice_service_code.sql | 1 - ...4__promote_project_slug_to_real_column.sql | 34 +++++++++++++ .../V25__add_show_project_boundaries.sql | 1 + ...nge_photo_voice_service_code_to_bigint.sql | 1 - .../V26__add_show_exit_project_mode.sql | 1 + ...6__promote_project_slug_to_real_column.sql | 23 --------- 16 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/app/service/SystemReservedGroupInitializer.java create mode 100644 app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql delete mode 100644 app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql delete mode 100644 app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql create mode 100644 app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql create mode 100644 app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql delete mode 100644 app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql create mode 100644 app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql delete mode 100644 app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql diff --git a/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java b/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java index 6f8e0936..1d88fd2a 100644 --- a/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java +++ b/app/src/main/java/app/dto/jurisdiction/JurisdictionDTO.java @@ -66,6 +66,12 @@ public class JurisdictionDTO { @JsonProperty("photo_voice_service_code") private Long photoVoiceServiceCode; + @JsonProperty("show_project_boundaries") + private boolean showProjectBoundaries; + + @JsonProperty("show_exit_project_mode") + private boolean showExitProjectMode; + @JsonProperty("closed_request_days_visible_user") private Integer closedRequestDaysVisibleUser; @@ -96,6 +102,8 @@ public JurisdictionDTO(Jurisdiction jurisdiction) { this.closedRequestDaysVisibleUser = jurisdiction.getClosedRequestDaysVisibleUser(); this.closedRequestDaysVisibleAdmin = jurisdiction.getClosedRequestDaysVisibleAdmin(); this.photoVoiceServiceCode = jurisdiction.getPhotoVoiceServiceCode(); + this.showProjectBoundaries = jurisdiction.isShowProjectBoundaries(); + this.showExitProjectMode = jurisdiction.isShowExitProjectMode(); } public JurisdictionDTO(Jurisdiction jurisdiction, JurisdictionBoundary boundary) { @@ -235,4 +243,20 @@ public Long getPhotoVoiceServiceCode() { public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { this.photoVoiceServiceCode = photoVoiceServiceCode; } + + public boolean isShowProjectBoundaries() { + return showProjectBoundaries; + } + + public void setShowProjectBoundaries(boolean showProjectBoundaries) { + this.showProjectBoundaries = showProjectBoundaries; + } + + public boolean isShowExitProjectMode() { + return showExitProjectMode; + } + + public void setShowExitProjectMode(boolean showExitProjectMode) { + this.showExitProjectMode = showExitProjectMode; + } } diff --git a/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java b/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java index 0d0e1053..dbca1ad0 100644 --- a/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java +++ b/app/src/main/java/app/dto/jurisdiction/PatchJurisdictionDTO.java @@ -61,6 +61,12 @@ public class PatchJurisdictionDTO { @JsonProperty("photo_voice_service_code") private Long photoVoiceServiceCode; + @JsonProperty("show_project_boundaries") + private Boolean showProjectBoundaries; + + @JsonProperty("show_exit_project_mode") + private Boolean showExitProjectMode; + @JsonProperty("closed_request_days_visible_user") private Integer closedRequestDaysVisibleUser; @@ -165,4 +171,20 @@ public Long getPhotoVoiceServiceCode() { public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { this.photoVoiceServiceCode = photoVoiceServiceCode; } + + public Boolean getShowProjectBoundaries() { + return showProjectBoundaries; + } + + public void setShowProjectBoundaries(Boolean showProjectBoundaries) { + this.showProjectBoundaries = showProjectBoundaries; + } + + public Boolean getShowExitProjectMode() { + return showExitProjectMode; + } + + public void setShowExitProjectMode(Boolean showExitProjectMode) { + this.showExitProjectMode = showExitProjectMode; + } } diff --git a/app/src/main/java/app/model/jurisdiction/Jurisdiction.java b/app/src/main/java/app/model/jurisdiction/Jurisdiction.java index 98bf420d..d6e34537 100644 --- a/app/src/main/java/app/model/jurisdiction/Jurisdiction.java +++ b/app/src/main/java/app/model/jurisdiction/Jurisdiction.java @@ -74,6 +74,10 @@ public class Jurisdiction { @Nullable private Long photoVoiceServiceCode; + private boolean showProjectBoundaries = true; + + private boolean showExitProjectMode = true; + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true, mappedBy = "jurisdiction") @OnDelete(action = OnDeleteAction.CASCADE) private Set remoteHosts = new HashSet<>(); @@ -227,4 +231,20 @@ public void setPhotoVoiceServiceCode(Long photoVoiceServiceCode) { this.photoVoiceServiceCode = photoVoiceServiceCode; } + public boolean isShowProjectBoundaries() { + return showProjectBoundaries; + } + + public void setShowProjectBoundaries(boolean showProjectBoundaries) { + this.showProjectBoundaries = showProjectBoundaries; + } + + public boolean isShowExitProjectMode() { + return showExitProjectMode; + } + + public void setShowExitProjectMode(boolean showExitProjectMode) { + this.showExitProjectMode = showExitProjectMode; + } + } \ No newline at end of file diff --git a/app/src/main/java/app/model/project/ProjectRepository.java b/app/src/main/java/app/model/project/ProjectRepository.java index 78b0b673..db2f361f 100644 --- a/app/src/main/java/app/model/project/ProjectRepository.java +++ b/app/src/main/java/app/model/project/ProjectRepository.java @@ -37,6 +37,9 @@ public interface ProjectRepository extends JpaRepository { Optional findBySlugAndJurisdictionId(String slug, String jurisdictionId); + @Query("SELECT count(p) FROM Project p WHERE p.jurisdiction.id = :jurisdictionId AND p.slug LIKE :slugPrefix") + long countSlugStartingWith(String jurisdictionId, String slugPrefix); + @Query("FROM Project p WHERE p.jurisdiction.id = :jurisdictionId AND p.startDate <= :time AND p.endDate >= :time AND intersects(p.boundary, :location) = true") Optional findProjectForLocationAndTime(String jurisdictionId, Point location, Instant time); } diff --git a/app/src/main/java/app/service/SystemReservedGroupInitializer.java b/app/src/main/java/app/service/SystemReservedGroupInitializer.java new file mode 100644 index 00000000..0480df26 --- /dev/null +++ b/app/src/main/java/app/service/SystemReservedGroupInitializer.java @@ -0,0 +1,49 @@ +// Copyright 2023 Libre311 Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app.service; + +import app.model.jurisdiction.Jurisdiction; +import app.model.jurisdiction.JurisdictionRepository; +import app.model.service.group.ServiceGroup; +import app.model.service.group.ServiceGroupRepository; +import io.micronaut.runtime.event.annotation.EventListener; +import io.micronaut.runtime.server.event.ServerStartupEvent; +import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; + +@Singleton +public class SystemReservedGroupInitializer { + + static final String SYSTEM_RESERVED = "System Reserved"; + + private final JurisdictionRepository jurisdictionRepository; + private final ServiceGroupRepository serviceGroupRepository; + + public SystemReservedGroupInitializer(JurisdictionRepository jurisdictionRepository, + ServiceGroupRepository serviceGroupRepository) { + this.jurisdictionRepository = jurisdictionRepository; + this.serviceGroupRepository = serviceGroupRepository; + } + + @EventListener + @Transactional + public void onStartup(ServerStartupEvent event) { + for (Jurisdiction jurisdiction : jurisdictionRepository.findAll()) { + if (!serviceGroupRepository.existsByNameAndJurisdiction(SYSTEM_RESERVED, jurisdiction)) { + serviceGroupRepository.save(new ServiceGroup(SYSTEM_RESERVED, jurisdiction)); + } + } + } +} diff --git a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java index 7ccd2425..cb7a6aac 100644 --- a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java +++ b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java @@ -22,6 +22,7 @@ import io.micronaut.context.annotation.Property; import io.micronaut.http.HttpStatus; import jakarta.inject.Singleton; +import jakarta.transaction.Transactional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -133,6 +134,7 @@ public JurisdictionDTO createJurisdiction(CreateJurisdictionDTO requestDTO, Long return new JurisdictionDTO(savedJurisdiction, savedBoundary); } + @Transactional public JurisdictionDTO updateJurisdiction(String jurisdictionId, PatchJurisdictionDTO requestDTO) { Optional jurisdictionOptional = jurisdictionRepository.findById(jurisdictionId); @@ -193,6 +195,12 @@ private void applyPatch(PatchJurisdictionDTO jurisdictionDTO, Jurisdiction juris jurisdictionDTO.getPhotoVoiceServiceCode().equals(0L) ? null : jurisdictionDTO.getPhotoVoiceServiceCode() ); } + if (jurisdictionDTO.getShowProjectBoundaries() != null) { + jurisdiction.setShowProjectBoundaries(jurisdictionDTO.getShowProjectBoundaries()); + } + if (jurisdictionDTO.getShowExitProjectMode() != null) { + jurisdiction.setShowExitProjectMode(jurisdictionDTO.getShowExitProjectMode()); + } } public JurisdictionDTO setJurisdictionRemoteHosts(String jurisdictionId, Set remoteHosts) { diff --git a/app/src/main/java/app/service/project/ProjectService.java b/app/src/main/java/app/service/project/ProjectService.java index 0bf56893..5014eae2 100644 --- a/app/src/main/java/app/service/project/ProjectService.java +++ b/app/src/main/java/app/service/project/ProjectService.java @@ -95,16 +95,16 @@ public ProjectDTO createProject(CreateProjectDTO dto, String jurisdictionId) { project.setEndDate(dto.getEndDate()); project.setJurisdiction(jurisdiction); - project = projectRepository.save(project); - project.setSlug(generateSlug(project.getName(), project.getId())); - return new ProjectDTO(projectRepository.update(project)); + project.setSlug(generateUniqueSlug(dto.getName(), jurisdictionId)); + return new ProjectDTO(projectRepository.save(project)); } - private static String generateSlug(String name, Long id) { + private String generateUniqueSlug(String name, String jurisdictionId) { String base = name.toLowerCase() .replaceAll("[^a-z0-9\\s]", "") .replaceAll("\\s+", "-"); - return id + "-" + base; + long count = projectRepository.countSlugStartingWith(jurisdictionId, base + "%"); + return count == 0 ? base : base + "-" + count; } @Transactional diff --git a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java index 3e958028..9cfe1007 100644 --- a/app/src/main/java/app/service/servicerequest/ServiceRequestService.java +++ b/app/src/main/java/app/service/servicerequest/ServiceRequestService.java @@ -187,7 +187,8 @@ public PostResponseServiceRequestDTO createServiceRequest(HttpRequest request ServiceRequest serviceRequest = transformDtoToServiceRequest(serviceRequestDTO, service); Jurisdiction jurisdiction = jurisdictionRepository.findByJurisdictionId(jurisdictionId); - if (jurisdiction.getProjectFeature() != ProjectFeature.DISABLED) { + boolean isPhotoVoice = service.getId().equals(jurisdiction.getPhotoVoiceServiceCode()); + if (jurisdiction.getProjectFeature() != ProjectFeature.DISABLED && !isPhotoVoice) { if (serviceRequestDTO.getProjectId() != null) { Project project = projectRepository.findByIdAndJurisdictionId(serviceRequestDTO.getProjectId(), jurisdictionId) .orElseThrow(() -> new InvalidServiceRequestException("Project not found")); @@ -196,8 +197,7 @@ public PostResponseServiceRequestDTO createServiceRequest(HttpRequest request Optional project = projectService.findProjectForLocationAndTime(serviceRequest.getLocation(), Instant.now(), jurisdictionId); if (project.isPresent()) { serviceRequest.setProject(project.get()); - } else if (jurisdiction.getProjectFeature() == ProjectFeature.REQUIRED - && !service.getId().equals(jurisdiction.getPhotoVoiceServiceCode())) { + } else if (jurisdiction.getProjectFeature() == ProjectFeature.REQUIRED) { throw new InvalidServiceRequestException("The service request does not fall within any active project boundaries, and a project is required for this jurisdiction."); } } diff --git a/app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql b/app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql new file mode 100644 index 00000000..031c77b6 --- /dev/null +++ b/app/src/main/resources/db/migration/V23__add_photo_voice_service_code.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN photo_voice_service_code BIGINT NULL; diff --git a/app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql b/app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql deleted file mode 100644 index 9885c6fb..00000000 --- a/app/src/main/resources/db/migration/V23__add_system_reserved_service_group.sql +++ /dev/null @@ -1,3 +0,0 @@ -INSERT INTO service_groups (name, jurisdiction_id) - SELECT 'System Reserved', id - FROM jurisdictions; diff --git a/app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql b/app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql deleted file mode 100644 index 923d3d6e..00000000 --- a/app/src/main/resources/db/migration/V24__add_photo_voice_service_code.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE jurisdictions ADD COLUMN photo_voice_service_code VARCHAR(255) NULL; diff --git a/app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql b/app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql new file mode 100644 index 00000000..86c2bbb8 --- /dev/null +++ b/app/src/main/resources/db/migration/V24__promote_project_slug_to_real_column.sql @@ -0,0 +1,34 @@ +-- Add standalone index so the jurisdiction_id FK retains index support +-- when we drop the compound index from V17 +ALTER TABLE projects ADD INDEX idx_projects_jurisdiction_id (jurisdiction_id); + +-- Drop the V17 compound index, then the virtual column +DROP INDEX idx_projects_jurisdiction_slug ON projects; +ALTER TABLE projects DROP COLUMN slug; + +-- Add a real slug column +ALTER TABLE projects ADD COLUMN slug VARCHAR(255) NOT NULL DEFAULT ''; + +-- Backfill existing rows using the counter approach: +-- partition by (jurisdiction_id, base_slug) ordered by id so the first project +-- gets the plain slug and subsequent ones get slug-1, slug-2, etc. +UPDATE projects p +JOIN ( + SELECT id, + base_slug, + ROW_NUMBER() OVER (PARTITION BY jurisdiction_id, base_slug ORDER BY id) - 1 AS cnt + FROM ( + SELECT id, + jurisdiction_id, + LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9 ]', ''), ' +', '-')) AS base_slug + FROM projects + ) base +) sub ON p.id = sub.id +SET p.slug = CASE WHEN sub.cnt = 0 THEN sub.base_slug ELSE CONCAT(sub.base_slug, '-', sub.cnt) END; + +ALTER TABLE projects ALTER COLUMN slug DROP DEFAULT; + +-- uk_projects_jurisdiction_slug covers jurisdiction_id as its leftmost prefix, +-- so the temporary standalone index is no longer needed +ALTER TABLE projects ADD UNIQUE KEY uk_projects_jurisdiction_slug (jurisdiction_id, slug); +DROP INDEX idx_projects_jurisdiction_id ON projects; diff --git a/app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql b/app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql new file mode 100644 index 00000000..d506510a --- /dev/null +++ b/app/src/main/resources/db/migration/V25__add_show_project_boundaries.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN show_project_boundaries BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql b/app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql deleted file mode 100644 index 56676067..00000000 --- a/app/src/main/resources/db/migration/V25__change_photo_voice_service_code_to_bigint.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE jurisdictions MODIFY COLUMN photo_voice_service_code BIGINT NULL; diff --git a/app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql b/app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql new file mode 100644 index 00000000..e53b36a5 --- /dev/null +++ b/app/src/main/resources/db/migration/V26__add_show_exit_project_mode.sql @@ -0,0 +1 @@ +ALTER TABLE jurisdictions ADD COLUMN show_exit_project_mode BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql b/app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql deleted file mode 100644 index c9f8e392..00000000 --- a/app/src/main/resources/db/migration/V26__promote_project_slug_to_real_column.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Add standalone index so the jurisdiction_id FK retains index support --- when we drop the compound index from V17 -ALTER TABLE projects ADD INDEX idx_projects_jurisdiction_id (jurisdiction_id); - --- Drop the V17 compound index, then the virtual column -DROP INDEX idx_projects_jurisdiction_slug ON projects; -ALTER TABLE projects DROP COLUMN slug; - --- Add a real slug column, populated from name + id for uniqueness and stability -ALTER TABLE projects ADD COLUMN slug VARCHAR(255); - -UPDATE projects SET slug = CONCAT( - id, - '-', - LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9 ]', ''), ' +', '-')) -); - -ALTER TABLE projects MODIFY COLUMN slug VARCHAR(255) NOT NULL; - --- uk_projects_jurisdiction_slug covers jurisdiction_id as its leftmost prefix, --- so the temporary standalone index is no longer needed -ALTER TABLE projects ADD UNIQUE KEY uk_projects_jurisdiction_slug (jurisdiction_id, slug); -DROP INDEX idx_projects_jurisdiction_id ON projects; From 27dac7da790251d42513abe60729a55a0bcf8daf Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Tue, 2 Jun 2026 14:44:54 -0400 Subject: [PATCH 05/12] Polish photo-voice/walk audit deliniation Signed-off-by: Jesse Elliott --- frontend/package-lock.json | 73 +++++-------------- .../SelectARequestCategory.svelte | 4 +- .../SelectLocation.svelte | 2 +- .../ServiceRequestDetailsForm.svelte | 2 +- .../CreateServiceRequestButton.svelte | 5 +- frontend/src/lib/components/MenuDrawer.svelte | 70 +++++++++++++++--- .../src/lib/components/ServiceRequest.svelte | 4 +- .../components/ServiceRequestDetails.svelte | 2 +- .../src/lib/services/Libre311/Libre311.ts | 4 + frontend/src/media/messages.json | 20 +++-- frontend/src/routes/admin/system/+page.svelte | 65 +++++++++++++++-- frontend/src/routes/issue/create/+page.svelte | 2 +- frontend/src/routes/issues/map/+layout.svelte | 2 +- .../src/routes/issues/table/+layout.svelte | 4 +- frontend/src/routes/projects/+page.svelte | 2 +- 15 files changed, 166 insertions(+), 95 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index df89ee93..e58b2ba4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -260,6 +260,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -283,6 +284,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1667,6 +1669,7 @@ "integrity": "sha512-nKNhUdt61vtD961kQpUk6vLDhpnV0yku5F1uYNWvrJYFV0+cGfmW7ol0JVMSjHMXlMtmmv2FTc+nPRrTFwb2UA==", "dev": true, "hasInstallScript": true, + "peer": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", @@ -1698,6 +1701,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.1.tgz", "integrity": "sha512-CGURX6Ps+TkOovK6xV+Y2rn8JKa8ZPUHPZ/NKgCxAmgBrXReavzFl8aOSCj3kQ1xqT7yGJj53hjcV/gqwDAaWA==", "dev": true, + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0-next.0 || ^2.0.0", "debug": "^4.3.4", @@ -4068,6 +4072,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.18.1.tgz", "integrity": "sha512-zct/MdJnVaRRNy9e84XnVtRv9Vf91/qqe+hZJtKanjojud4wAVy/7lXxJmMyX6X6J+xc6c//YEWvpeif8cAhWA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.18.1", "@typescript-eslint/types": "6.18.1", @@ -4331,6 +4336,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4589,6 +4595,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -5334,6 +5341,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6389,6 +6397,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6419,6 +6428,7 @@ "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", @@ -7243,6 +7253,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7301,6 +7312,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -7412,6 +7424,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.1.tgz", "integrity": "sha512-qSUWshj1IobVbKc226Gw2pync27t0Kf0EdufZa9j7uBSJay1CC+B3K5lAAZoqgX3ASiKuWsk6OmzKRetXNObWg==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7427,6 +7440,7 @@ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz", "integrity": "sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==", "dev": true, + "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -7827,28 +7841,6 @@ "rimraf": "bin.js" } }, - "node_modules/sass": { - "version": "1.97.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", - "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, "node_modules/sass-embedded": { "version": "1.97.3", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.97.3.tgz", @@ -8213,38 +8205,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -8634,6 +8594,7 @@ "version": "4.2.19", "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -8834,6 +8795,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9141,6 +9103,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9233,6 +9196,7 @@ "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9344,6 +9308,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", diff --git a/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte b/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte index 716de376..30b97677 100644 --- a/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte +++ b/frontend/src/lib/components/CreateServiceRequest/SelectARequestCategory.svelte @@ -105,7 +105,7 @@ diff --git a/frontend/src/routes/projects/+page.svelte b/frontend/src/routes/projects/+page.svelte index 5ff22e72..89cbf4ca 100644 --- a/frontend/src/routes/projects/+page.svelte +++ b/frontend/src/routes/projects/+page.svelte @@ -33,7 +33,7 @@ { column: 'end_date', label: 'End Date', class: 'w-1/6', placement: 'left' }, { column: 'closed_date', label: 'Closed Date', class: 'w-1/6', placement: 'left' }, { column: 'status', label: 'Status', class: 'w-1/6', placement: 'left' }, - { column: 'request_count', label: 'Requests', class: 'w-1/12', placement: 'left' } + { column: 'request_count', label: 'Submissions', class: 'w-1/12', placement: 'left' } ]; let showAllClosed = false; From ead4364a5b18a80cd5e04ba882f7ab7f5e8a8963 Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Tue, 2 Jun 2026 15:32:49 -0400 Subject: [PATCH 06/12] Add icon to submit Signed-off-by: Jesse Elliott --- frontend/src/lib/components/CreateServiceRequestButton.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/lib/components/CreateServiceRequestButton.svelte b/frontend/src/lib/components/CreateServiceRequestButton.svelte index c5844395..e598a12f 100644 --- a/frontend/src/lib/components/CreateServiceRequestButton.svelte +++ b/frontend/src/lib/components/CreateServiceRequestButton.svelte @@ -26,6 +26,9 @@ {/if} {#if showLogPoint} {/if} From 5777c40e6369de2c133130ed0076ee26981dc39f Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Tue, 2 Jun 2026 15:33:04 -0400 Subject: [PATCH 07/12] Fix Intellij Gradle support Signed-off-by: Jesse Elliott --- build.gradle | 3 +++ gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 build.gradle diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..58844a64 --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +plugins { + id("com.bmuschko.docker-remote-api") version "9.4.0" apply false +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2b..5dd3c012 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From eaf131f41766054883a3abc354caa45ba793cdbf Mon Sep 17 00:00:00 2001 From: Jesse Elliott Date: Wed, 3 Jun 2026 10:29:45 -0400 Subject: [PATCH 08/12] Run format Signed-off-by: Jesse Elliott --- .../PhotoVoiceDetailsForm.svelte | 7 ++- .../components/ServiceRequestDetails.svelte | 4 +- frontend/src/routes/admin/system/+page.svelte | 53 ++++++++++++++----- .../routes/photo-voice/create/+page.svelte | 16 +++--- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte b/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte index 331958b6..8da48113 100644 --- a/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte +++ b/frontend/src/lib/components/CreateServiceRequest/PhotoVoiceDetailsForm.svelte @@ -70,7 +70,12 @@

{service.description}

{/if} -