Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/java/backendlab/team4you/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ SecurityFilterChain securityFilterChain(HttpSecurity http,
.requestMatchers(HttpMethod.GET, "/api/files/download/**").hasRole(ADMIN)
.requestMatchers(HttpMethod.DELETE, "/api/files/delete/**").hasRole(ADMIN)

.requestMatchers(HttpMethod.POST, "/case-officer/cases/**").hasRole(CASE_OFFICER)
.requestMatchers(HttpMethod.GET, "/case-officer/cases/**").hasRole(CASE_OFFICER)
.requestMatchers(HttpMethod.DELETE, "/case-officer/cases/**").hasRole(CASE_OFFICER)


.requestMatchers("/webauthn/**").hasAnyRole("USER", ADMIN, CASE_OFFICER)
.requestMatchers("/admin/**").hasRole(ADMIN)
.requestMatchers("/case-officer/**").hasRole(CASE_OFFICER)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,50 @@
package backendlab.team4you.controller;

import backendlab.team4you.casefile.CaseFile;
import backendlab.team4you.casefile.CaseFileListItemDto;
import backendlab.team4you.casefile.CaseFileService;
import backendlab.team4you.caserecord.CaseRecord;
import backendlab.team4you.caserecord.CaseRecordRepository;
import backendlab.team4you.caserecord.CaseRecordService;
import backendlab.team4you.caserecord.CaseStatus;
import backendlab.team4you.common.ConfidentialityLevel;
import backendlab.team4you.exceptions.CaseRecordNotFoundException;
import backendlab.team4you.exceptions.UserNotFoundException;
import backendlab.team4you.user.UserEntity;
import backendlab.team4you.user.UserRepository;
import backendlab.team4you.user.UserService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.List;

@Controller
@PreAuthorize("hasRole('CASE_OFFICER')")
public class CaseOfficerController {

private final CaseRecordRepository caseRecordRepository;
private final UserRepository userRepository;
private final UserService userService;
private final CaseFileService caseFileService;

public CaseOfficerController(CaseRecordRepository caseRecordRepository, UserRepository userRepository) {
public CaseOfficerController(CaseRecordRepository caseRecordRepository, UserRepository userRepository, UserService userService, CaseFileService caseFileService) {
this.caseRecordRepository = caseRecordRepository;
this.userRepository = userRepository;
this.userService = userService;
this.caseFileService = caseFileService;
}

@GetMapping("/case-officer")
Expand All @@ -41,6 +55,7 @@ public String caseOfficerHome() {
@GetMapping("/case-officer/cases")
public String listCases(
@RequestParam(defaultValue = "0") int page,
@RequestHeader(value = "HX-Request", required = false) String htmxRequest,
Authentication auth,
Model model
) {
Expand All @@ -54,7 +69,10 @@ public String listCases(
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", cases.getTotalPages());

return "fragments/case-officer-cases :: content";
if (htmxRequest != null) {
return "fragments/case-officer-cases :: content";
}
return "case-officer-cases";
}

@PostMapping("/case-officer/cases/close")
Expand All @@ -75,4 +93,85 @@ public String closeCase(@RequestParam String id, Authentication auth) {

return "";
}
@GetMapping("/case-officer/cases/{caseRecordId}/files")
public String getCaseFiles(@PathVariable Long caseRecordId, Principal principal, Model model) {
UserEntity currentUser = userService.getCurrentUser(principal);
List<CaseFileListItemDto> files = caseFileService.listFileItemsForViewer(caseRecordId, currentUser);

model.addAttribute("files", files);
model.addAttribute("caseRecordId", caseRecordId);

return "fragments/case-management/case-file-list-officer :: caseFileList";
}


@PostMapping("/case-officer/cases/{caseRecordId}/files")
public String uploadFile(
@PathVariable Long caseRecordId,
@RequestParam("file") MultipartFile file,
@RequestParam("confidentialityLevel") ConfidentialityLevel confidentialityLevel,
Principal principal,
Model model
) {
UserEntity currentUser = userService.getCurrentUser(principal);

try {
caseFileService.uploadFile(caseRecordId, file, confidentialityLevel, currentUser);
model.addAttribute("successMessage", "Filen laddades upp");
} catch (Exception e) {
model.addAttribute("errorMessage", "Kunde inte ladda upp filen: " + e.getMessage());
}

return getCaseFiles(caseRecordId, principal, model);
}
Comment on lines +108 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: misleading throws IOException and awkward error message.

uploadFile declares throws IOException, but every checked exception path is swallowed by the catch (Exception e), so the declaration can never trigger. Also, the user‑facing string "Error to upload: " reads awkwardly — consider "Kunde inte ladda upp filen: " to match the rest of the Swedish UI in this template.

Additionally, broad catch (Exception) here will mask real problems (e.g. authorization failures from CaseFileAccessService) by surfacing their raw messages to the end user. Consider catching the specific exception types you expect from the service.

♻️ Suggested cleanup
-    public String uploadFile(
+    public String uploadFile(
             `@PathVariable` Long caseRecordId,
             `@RequestParam`("file") MultipartFile file,
             `@RequestParam`("confidentialityLevel") ConfidentialityLevel confidentialityLevel,
             Principal principal,
             Model model
-    ) throws IOException {
+    ) {
         UserEntity currentUser = userService.getCurrentUser(principal);
 
         try {
             caseFileService.uploadFile(caseRecordId, file, confidentialityLevel, currentUser);
-            model.addAttribute("successMessage", "File uploaded");
+            model.addAttribute("successMessage", "Filen laddades upp");
         } catch (Exception e) {
-            model.addAttribute("errorMessage", "Error to upload: " + e.getMessage());
+            model.addAttribute("errorMessage", "Kunde inte ladda upp filen: " + e.getMessage());
         }
 
         return getCaseFiles(caseRecordId, principal, model);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/backendlab/team4you/controller/CaseOfficerController.java`
around lines 104 - 122, The uploadFile method currently declares throws
IOException and catches broad Exception which never lets the throws trigger and
may leak internal errors to users; update the uploadFile method to remove the
throws IOException, replace the catch (Exception e) with specific catches (e.g.
IOException, ValidationException, and the domain exception thrown by
CaseFileAccessService) and handle each appropriately (showing a Swedish user
message like "Kunde inte ladda upp filen: " + e.getMessage() for expected
failures), and let unexpected runtime exceptions propagate (or map them to a
generic error) instead of catching Exception; locate symbols uploadFile,
caseFileService.uploadFile and CaseFileAccessService to implement these changes.


@GetMapping("/case-officer/cases/{caseRecordId}/files/{fileId}")
public ResponseEntity<StreamingResponseBody> downloadFile(
@PathVariable Long caseRecordId,
@PathVariable Long fileId,
Principal principal
) {
UserEntity currentUser = userService.getCurrentUser(principal);
CaseFile caseFile = caseFileService.getCaseFileForViewer(caseRecordId, fileId, currentUser);

MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
if (caseFile.getContentType() != null && !caseFile.getContentType().isBlank()) {
mediaType = MediaType.parseMediaType(caseFile.getContentType());
}

StreamingResponseBody body = outputStream -> {
try (InputStream stream = caseFileService.downloadFile(caseRecordId, fileId, currentUser)) {
stream.transferTo(outputStream);
}
};

return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(caseFile.getOriginalFilename(), StandardCharsets.UTF_8)
.build()
.toString()
)
.contentType(mediaType)
.body(body);
}
Comment on lines +128 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm visibility/signature of getCaseFileForViewer on CaseFileService.
fd -t f 'CaseFileService.java' --exec rg -nP '(?m)^\s*(public|protected|private)\s+\S+\s+getCaseFileForViewer\s*\(' {}

Repository: ithsjava25/project-backend-team4you

Length of output: 177


🏁 Script executed:

fd -t f 'CaseFileService.java' --exec cat -n {} \; | head -300

Repository: ithsjava25/project-backend-team4you

Length of output: 11762


Redundant authorization and DB lookup during file download.

getCaseFileForViewer(...) is called on line 131 to resolve metadata, and downloadFile(...) (line 139) internally calls getCaseFileForViewer(...) again, duplicating both the authorization check and database lookup per download. Consider refactoring to pass the already-fetched CaseFile to downloadFile(...), or creating a combined method (e.g., prepareDownload(...)) that returns both the metadata and an InputStream supplier in a single operation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/backendlab/team4you/controller/CaseOfficerController.java`
around lines 124 - 154, The controller currently calls getCaseFileForViewer(...)
to fetch metadata and then calls downloadFile(...) which internally repeats the
same authorization/DB lookup; refactor by changing downloadFile(...) to accept
the already-fetched CaseFile (e.g., downloadFile(Long caseRecordId, Long fileId,
UserEntity user, CaseFile caseFile) or downloadFile(CaseFile caseFile,
UserEntity user)) or alternatively implement a new service method
prepareDownload(...) that atomically returns both the CaseFile metadata and an
InputStream supplier/stream in one call; update the controller's downloadFile
handler to use the new signature (or prepareDownload result) so you only perform
authorization/DB lookup once (references: CaseOfficerController.downloadFile,
caseFileService.getCaseFileForViewer, caseFileService.downloadFile, propose
caseFileService.prepareDownload).


@DeleteMapping("/case-officer/cases/{caseRecordId}/files/{fileId}")
public String deleteFile(
@PathVariable Long caseRecordId,
@PathVariable Long fileId,
Principal principal,
Model model
) {

UserEntity currentUser = userService.getCurrentUser(principal);
try {
caseFileService.deleteFile(caseRecordId, fileId, currentUser);
model.addAttribute("successMessage", "Filen togs bort");
} catch (Exception e) {
model.addAttribute("errorMessage", "Kunde inte ta bort filen: " + e.getMessage());
}
return getCaseFiles(caseRecordId, principal, model);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
15 changes: 13 additions & 2 deletions src/main/resources/templates/case-officer-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
<link rel="stylesheet" th:href="@{/css/admin.css}">
<link rel="stylesheet" th:href="@{/components/form.css}">


<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>

<body>
Expand All @@ -23,7 +24,17 @@ <h1>Välkommen Handläggare</h1>
</div>
</main>


<script src="https://unpkg.com/htmx.org@1.9.10"></script>

<script>
document.addEventListener('htmx:configRequest', function (evt) {
var token = document.querySelector('meta[name="_csrf"]');
var header = document.querySelector('meta[name="_csrf_header"]');
if (token && header) {
evt.detail.headers[header.content] = token.content;
}
});
</script>

</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<div th:fragment="caseFileList" class="case-file-panel">

<h3>Filer</h3>

<p th:if="${successMessage}"
th:text="${successMessage}"
class="feedback-message success-message"></p>

<p th:if="${errorMessage}"
th:text="${errorMessage}"
class="feedback-message error-message"></p>

<form th:action="@{/case-officer/cases/{caseId}/files(caseId=${caseRecordId})}"
th:attr="hx-post=@{/case-officer/cases/{caseId}/files(caseId=${caseRecordId})}"
method="post"
enctype="multipart/form-data"
hx-encoding="multipart/form-data"
hx-target="closest .case-file-panel"
hx-swap="outerHTML">

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>

<div class="form-group">
<label for="case-file-upload">Ladda upp fil</label>
<input id="case-file-upload" type="file" name="file" required>
</div>

<div class="form-group">
<label for="file-confidentiality-officer">Sekretessnivå</label>
<select id="file-confidentiality-officer" name="confidentialityLevel">
<option value="OPEN">Ingen sekretess</option>
<option value="CONFIDENTIAL">Sekretess</option>
</select>
</div>

<button type="submit" class="btn-primary">Ladda upp</button>
</form>

<ul class="case-file-list">
<li th:if="${#lists.isEmpty(files)}">Inga filer uppladdade ännu.</li>

<li th:each="file : ${files}" class="case-file-item file-row">
<div class="case-file-info">
<span class="case-file-reference"
th:text="${file.documentReference}">KS26-1-1</span>

<span class="case-file-name"
th:text="${file.displayName}">dokument.pdf</span>

<span class="case-file-lock"
th:if="${file.confidential}"
title="Sekretessbelagd fil">🔒</span>
</div>

<div class="case-file-actions file-actions">
<a th:if="${file.canDownload}"
th:href="@{/case-officer/cases/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
class="btn-secondary">
Ladda ner
</a>

<span th:unless="${file.canDownload}"
class="case-file-restricted-text">
Ingen behörighet
</span>

<button type="button"
class="btn-danger"
th:attr="hx-delete=@{/case-officer/cases/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
hx-target="closest .case-file-panel"
hx-swap="outerHTML"
hx-confirm="Är du säker på att du vill ta bort denna fil?">
Ta bort
</button>
Comment on lines +67 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Delete swaps in an empty body, erasing the file panel.

The HTMX delete uses hx-target="closest .case-file-panel" and hx-swap="outerHTML", but the controller’s deleteFile returns ResponseEntity.ok().build() (empty body, see CaseOfficerController.java line 167). HTMX will replace the whole .case-file-panel with an empty string, so the user loses the upload form and the rest of the file list right after deleting one file.

Either re-render the file list fragment from the delete endpoint (recommended, mirrors the upload flow) or change hx-swap to delete/outerHTML on the <li> only.

🐛 Recommended template-side variant (if you keep an empty 200 response)
-                <button type="button"
-                        class="btn-danger"
-                        th:attr="hx-delete=@{/case-officer/cases/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
-                        th:hx-headers="${_csrf != null} ? '{&quot;' + ${_csrf.headerName} + '&quot;:&quot;' + ${_csrf.token} + '&quot;}' : null"
-                        hx-target="closest .case-file-panel"
-                        hx-swap="outerHTML"
-                        hx-confirm="Är du säker på att du vill ta bort denna fil?">
+                <button type="button"
+                        class="btn-danger"
+                        th:attr="hx-delete=@{/case-officer/cases/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
+                        hx-target="closest .case-file-item"
+                        hx-swap="outerHTML swap:200ms"
+                        hx-confirm="Är du säker på att du vill ta bort denna fil?">

The preferred fix is on the controller side — return the same caseFileList fragment as the upload endpoint does.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button type="button"
class="btn-danger"
th:attr="hx-delete=@{/case-officer/cases/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
th:hx-headers="${_csrf != null} ? '{&quot;' + ${_csrf.headerName} + '&quot;:&quot;' + ${_csrf.token} + '&quot;}' : null"
hx-target="closest .case-file-panel"
hx-swap="outerHTML"
hx-confirm="Är du säker på att du vill ta bort denna fil?">
Ta bort
</button>
<button type="button"
class="btn-danger"
th:attr="hx-delete=@{/case-officer/cases/{caseId}/files/{fileId}(caseId=${caseRecordId}, fileId=${file.id})}"
hx-target="closest .case-file-item"
hx-swap="outerHTML swap:200ms"
hx-confirm="Är du säker på att du vill ta bort denna fil?">
Ta bort
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/resources/templates/fragments/case-management/case-file-list-officer.html`
around lines 65 - 73, The delete button currently uses hx-target="closest
.case-file-panel" and hx-swap="outerHTML" but the controller method
CaseOfficerController.deleteFile returns ResponseEntity.ok().build(), causing
HTMX to replace the whole .case-file-panel with an empty body; fix by updating
the server or template: either modify CaseOfficerController.deleteFile to return
the same case-file-list fragment (render the updated fragment and return it,
mirroring the upload flow) so outerHTML replacement keeps the UI intact, or
change the button in case-file-list-officer.html to target the specific <li>
(e.g., hx-target="closest li" or a class on the file row) and use
hx-swap="delete" so only the removed file element is removed from the DOM.

</div>
</li>
</ul>
</div>
25 changes: 18 additions & 7 deletions src/main/resources/templates/fragments/case-officer-cases.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,38 @@ <h2 class="mb-4">Mina ärenden</h2>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h6 class="card-subtitle mb-2 text-muted">Beskrivning</h6>
<p class="card-text" th:text="${case.description}">
</p>
<h3 class="card-subtitle mb-2 text-muted">Beskrivning</h3>
<p class="card-text" th:text="${case.description}"></p>
</div>
<div class="col-md-4 border-start">
<p class="mb-1 small"><strong>Registry:</strong> <span th:text="${case.registry.name}">KS</span></p>
<p class="mb-1 small"><strong>Skapad:</strong> <span th:text="${#temporals.format(case.createdAt, 'yyyy-MM-dd HH:mm')}">2026-04-23</span></p>
<p class="mb-1 small"><strong>Ägare:</strong> <span th:text="${case.owner.displayName}">Admin Name</span></p>
</div>
</div>
</div>

<div class="card-footer bg-transparent border-top-0">
<div class="mt-3 file-section">
<button type="button"
class="btn btn-outline-primary btn-sm"
th:attr="hx-get=@{/case-officer/cases/{id}/files(id=${case.id})}"
hx-target="next .file-container"
hx-swap="innerHTML">
<i class="fa-solid fa-folder"></i> Ser Filer
</button>

<div th:id="'files-' + ${case.id}" class="mt-2 file-container"></div>
</div>
</div>


<div class="card-footer bg-transparent border-top-0">
<button
class="btn btn-outline-secondary btn-sm float-end"
th:if="${case.status.name() == 'OPEN'}"
th:attr="hx-post=@{/case-officer/cases/close(id=${case.id})}"
hx-target="closest .col-12"
hx-swap="outerHTML"
hx-confirm="Är du säker på att du vill stänga detta ärende?"
>
hx-confirm="Är du säker på att du vill stänga detta ärende?">
<i class="bi bi-x-circle"></i> Stäng ärende
</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
<li>
<a th:href="@{/case-officer/cases}"
hx-get="/case-officer/cases"
hx-target="#content-area">
hx-target="#content-area"
hx-swap="innerHTML"
hx-push-url="true">
<i class="fa-solid fa-file-invoice"></i>
<span>Hantera ärenden</span>
</a>
Expand Down