diff options
| author | Paul-Christian Volkmer | 2026-03-07 11:09:38 +0100 |
|---|---|---|
| committer | GitHub | 2026-03-07 10:09:38 +0000 |
| commit | 17262ea8cf9478bab2b5c34d814c8e1519adf33a (patch) | |
| tree | 3abd2928ea19f8790fc523fa0f54a6ed6bec9c04 /src | |
| parent | ee5f9096c85f6789078597ba19f7c02e6b24d2c5 (diff) | |
feat: search by patient pseudonym and TAN (#256)
Diffstat (limited to 'src')
6 files changed, 189 insertions, 52 deletions
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt index b0b4ecb..2a8ce81 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt @@ -41,6 +41,9 @@ import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyValueClass +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest @@ -48,6 +51,8 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import org.springframework.security.test.context.support.WithMockUser +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource import org.springframework.test.context.bean.override.mockito.MockitoBean @@ -259,7 +264,7 @@ class HomeControllerTest { @Test fun testShouldShowHomePage() { val page = webClient.getPage<HtmlPage>("http://localhost/") - assertThat(page.querySelectorAll("tbody tr")).isEmpty() + assertThat(page.querySelectorAll("div.card")).isEmpty() assertThat(page.querySelectorAll("div.notification.info")).hasSize(1) } @@ -283,7 +288,7 @@ class HomeControllerTest { .thenReturn(Page.empty()) val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1") - assertThat(page.querySelectorAll("tbody tr")).isEmpty() + assertThat(page.querySelectorAll("div.card")).isEmpty() assertThat(page.querySelectorAll("div.notification.info")).hasSize(1) } @@ -313,5 +318,77 @@ class HomeControllerTest { assertThat(page.querySelectorAll("div.card div").first().textContent) .isEqualTo("Gestoppt: Kein Consent") } + + @Test + fun testSearchAsLoggedInAdmin() { + whenever(requestService.searchRequestLike(anyValueClass(), anyValueClass(), any<Pageable>())) + .thenReturn(Page.empty()) + + mockMvc.get("/") { + queryParam("q", "test") + with(user("admin").roles("ADMIN")) + }.andExpect { + status { isOk() } + view { name("index") } + } + + verify(requestService, times(1)) + .searchRequestLike( + anyValueClass<PatientPseudonym>(), + anyValueClass<Tan>(), + any<Pageable>() + ) + + verify(requestService, times(0)) + .findAll(any<Pageable>()) + } + + @Test + fun testSearchAsLoggedInUser() { + whenever(requestService.searchRequestLike(anyValueClass(), anyValueClass(), any<Pageable>())) + .thenReturn(Page.empty()) + + mockMvc.get("/") { + queryParam("q", "test") + with(user("user").roles("USER")) + }.andExpect { + status { isOk() } + view { name("index") } + } + + verify(requestService, times(1)) + .searchRequestLike( + anyValueClass<PatientPseudonym>(), + anyValueClass<Tan>(), + any<Pageable>() + ) + + verify(requestService, times(0)) + .findAll(any<Pageable>()) + } + + @Test + fun testNotSearchAsAnonymousUser() { + whenever(requestService.searchRequestLike(anyValueClass(), anyValueClass(), any<Pageable>())) + .thenReturn(Page.empty()) + + mockMvc.get("/") { + queryParam("q", "test") + with(anonymous()) + }.andExpect { + status { isOk() } + view { name("index") } + } + + verify(requestService, times(0)) + .searchRequestLike( + anyValueClass<PatientPseudonym>(), + anyValueClass<Tan>(), + any<Pageable>() + ) + + verify(requestService, times(1)) + .findAll(any<Pageable>()) + } } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt index 9aede20..4ed071d 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt @@ -144,4 +144,6 @@ interface RequestRepository : ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;" ) fun findPatientUniqueDeleteStates(): List<CountedState> + + fun findByPatientPseudonymContainingIgnoreCaseOrTanContainingIgnoreCase(patientPseudonym: PatientPseudonym, tan: Tan, pageable: Pageable): Page<Request> } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt index e7cb95f..3a2ea35 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.services import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.Tan import dev.dnpm.etl.processor.monitoring.* import java.util.* import org.springframework.data.domain.Page @@ -36,6 +37,9 @@ class RequestService(private val requestRepository: RequestRepository) { fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable) + fun searchRequestLike(patientPseudonym: PatientPseudonym, tan: Tan, pageable: Pageable): Page<Request> = + requestRepository.findByPatientPseudonymContainingIgnoreCaseOrTanContainingIgnoreCase(patientPseudonym, tan, pageable) + fun findByUuid(uuid: RequestId): Optional<Request> = requestRepository.findByUuidEquals(uuid) fun findRequestByPatientId( diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt index 9262c29..6a76c38 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt @@ -22,12 +22,14 @@ package dev.dnpm.etl.processor.web import dev.dnpm.etl.processor.NotFoundException import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.Tan import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.services.RequestService import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.data.web.PageableDefault +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Controller import org.springframework.ui.Model import org.springframework.web.bind.annotation.DeleteMapping @@ -35,6 +37,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam @Controller @RequestMapping(path = ["/"]) @@ -45,11 +48,26 @@ class HomeController( ) { @GetMapping fun index( + @RequestParam(name = "q", required = false) queryString: String?, @PageableDefault(page = 0, size = 10, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable, model: Model, ): String { - val requests = requestService.findAll(pageable) + val isAdminOrUser = + SecurityContextHolder + .getContext() + .authentication + ?.authorities + ?.any { it.authority == "ROLE_USER" || it.authority == "ROLE_ADMIN" } == true + + val requests = + // Only available for logged-in admins or users + if (null != queryString && isAdminOrUser) { + model.addAttribute("query", queryString) + requestService.searchRequestLike(PatientPseudonym(queryString), Tan(queryString), pageable) + } else { + requestService.findAll(pageable) + } model.addAttribute("requests", requests) model.addAttribute("postInitialSubmissionBlock", appConfigProperties.postInitialSubmissionBlock) return "index" diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index db85196..cea3f73 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -9,6 +9,13 @@ <div th:replace="~{fragments.html :: nav}"></div> <main> + <div class="search-form" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> + <form th:action="@{/}" method="get"> + <input id="search-input" type="text" name="q" maxlength="64" placeholder="Suche nach Patienten-Pseudonym oder TAN" th:value="${query}"/> + <a th:href="@{/}">🞫</a> + </form> + </div> + <h1> Alle Anfragen <a id="reload-notify" class="btn btn-red reload" title="Neue Anfragen laden" th:href="@{/}"> @@ -19,7 +26,12 @@ <div> <h2 th:if="${patientPseudonym != null}"> Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span> - <a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a> + <a class="btn btn-blue" th:href="@{/}">Alle anzeigen</a> + </h2> + + <h2 th:if="not ${null == query || query.isBlank()}"> + Für die Suche nach „<span class="monospace" th:text="${query}">***</span>“ + <a class="btn btn-blue" th:href="@{/}">Alle anzeigen</a> </h2> </div> @@ -27,7 +39,7 @@ <div class="notification info">Noch keine Anfragen eingegangen</div> </div> - <div class="border" th:if="${requests.totalElements > 0}"> + <div th:if="${requests.totalElements > 0}"> <div class="paged"> <div class="card" th:each="request : ${requests}"> <div th:if="${request.status.value.contains('success')}" class="card-header bg-green">Erfolgreiche Übertragung</div> @@ -77,13 +89,20 @@ </div> </div> </div> - <div th:if="${patientPseudonym == null}" class="page-control"> + <div th:if="${patientPseudonym == null && query == null}" class="page-control"> <a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a> <a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span> <a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a> <a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a> </div> + <div th:if="${patientPseudonym == null && query != null}" class="page-control"> + <a id="first-page-link" th:href="@{/(q=${query},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a> + <a id="prev-page-link" th:href="@{/(q=${query},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a> + <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span> + <a id="next-page-link" th:href="@{/(q=${query},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a> + <a id="last-page-link" th:href="@{/(q=${query},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a> + </div> <div th:if="${patientPseudonym != null}" class="page-control"> <a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a> <a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a> @@ -105,10 +124,12 @@ 's': 'last-page-link' }; window.onkeydown = (event) => { - for (const [key, elemId] of Object.entries(keyBindings)) { - if (event.key === key && document.getElementById(elemId)) { - document.getElementById(elemId).style.background = 'yellow'; - document.getElementById(elemId).click(); + if (document.activeElement.id !== 'search-input') { + for (const [key, elemId] of Object.entries(keyBindings)) { + if (event.key === key && document.getElementById(elemId)) { + document.getElementById(elemId).style.background = 'yellow'; + document.getElementById(elemId).click(); + } } } }; diff --git a/src/web/style.css b/src/web/style.css index f5396be..a82e8de 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -43,6 +43,10 @@ body { background-size: contain; } +code { + font-family: monospace; +} + div.headline { position: fixed; display: block; @@ -246,47 +250,6 @@ section { margin: 3rem 0; } -form { - margin: 1rem 0; - padding: 1rem; - - border: 1px solid lightgray; - border-radius: 3px; - background: #eee; - - text-align: center; -} - -form > h2 { - margin: 0; -} - -form.samplecode-input > div { - padding: 0.6rem; - display: inline-block; - - border: 1px solid lightgray; - border-radius: 3px; - - background: white; -} - -form.samplecode-input input { - padding: 0; - - border: none; - outline: none; - - text-align: left; - appearance: textfield; - font-size: 1.2rem; - font-weight: bold; -} - -form.samplecode-input input:focus-visible { - background: lightgreen; -} - .login-form { width: fit-content; margin: 3rem auto; @@ -313,6 +276,56 @@ form.samplecode-input input:focus-visible { display: block; } +.search-form { + width: max-content; + margin: 0 0 0 auto; +} + +.search-form form { + width: max-content; + border: 1px solid var(--table-border); + border-radius: 3px; + transition: 0.2s; +} + +.search-form form:has(input:focus) { + border: 1px solid var(--bg-blue); +} + +.search-form form input { + width: 34rem; + border: none; + display: inline-block; + outline: none; + font-family: monospace; +} + +.search-form form a { + background: var(--text); + color: #fff; + aspect-ratio: 1; + text-align: center; + border-radius: 50%; + align-content: center; + height: 1.2rem; + font-size: 1.2rem; + text-decoration: none; + display: inline-grid; + padding: 0; + margin-right: .5rem; +} + +.token-form { + margin: 1rem 0; + padding: 1rem; + + border: 1px solid lightgray; + border-radius: 3px; + background: #eee; + + text-align: center; +} + .userrole-form { display: inline-block; } @@ -329,7 +342,8 @@ form.samplecode-input input:focus-visible { } .login-form form *, -.token-form form * { +.token-form form *, +.search-form form * { padding: 0.5rem; border: 1px solid var(--table-border); border-radius: 3px; @@ -349,6 +363,7 @@ form.samplecode-input input:focus-visible { background: var(--bg-blue); color: white; border: none; + text-align: center; } .userrole-form form select { |
