summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul-Christian Volkmer2026-03-07 11:09:38 +0100
committerGitHub2026-03-07 10:09:38 +0000
commit17262ea8cf9478bab2b5c34d814c8e1519adf33a (patch)
tree3abd2928ea19f8790fc523fa0f54a6ed6bec9c04
parentee5f9096c85f6789078597ba19f7c02e6b24d2c5 (diff)
feat: search by patient pseudonym and TAN (#256)
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt81
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt2
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt4
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt20
-rw-r--r--src/main/resources/templates/index.html35
-rw-r--r--src/web/style.css99
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 &#8222;<span class="monospace" th:text="${query}">***</span>&#8220;
+ <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()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
<a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</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()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
<a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</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()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</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()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</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()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
+ <a id="last-page-link" th:href="@{/(q=${query},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</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()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</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()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</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 {