diff --git a/init-localstack.sh b/init-localstack.sh index adfd77a..582bf66 100644 --- a/init-localstack.sh +++ b/init-localstack.sh @@ -1,4 +1,10 @@ #!/bin/bash set -euo pipefail + bucket="${AWS_BUCKET_NAME:-team4you-files}" -awslocal s3api head-bucket --bucket "$bucket" 2>/dev/null || awslocal s3 mb "s3://$bucket" \ No newline at end of file +echo "checking/creating s3 bucket: $bucket" + +awslocal s3api head-bucket --bucket "$bucket" 2>/dev/null || awslocal s3 mb "s3://$bucket" + +echo "available buckets:" +awslocal s3 ls \ No newline at end of file diff --git a/src/main/java/backendlab/team4you/Team4youApplication.java b/src/main/java/backendlab/team4you/Team4youApplication.java index 017c520..f55eb73 100644 --- a/src/main/java/backendlab/team4you/Team4youApplication.java +++ b/src/main/java/backendlab/team4you/Team4youApplication.java @@ -2,6 +2,7 @@ import backendlab.team4you.user.UserRepository; import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -25,12 +26,12 @@ ApplicationRunner init(UserRepository repository, BCryptPasswordEncoder encoder) UserEntity devAdmin = new UserEntity( Bytes.fromBase64("YWRtaW4="), - "dev", // name (username) - "admin" // displayName + "dev", + "admin" ); devAdmin.setPasswordHash(encoder.encode("123456")); - devAdmin.setRole("ROLE_ADMIN"); + devAdmin.setRole(UserRole.ADMIN); devAdmin.setEmail("devadmin@gmail.com"); repository.save(devAdmin); @@ -38,12 +39,12 @@ ApplicationRunner init(UserRepository repository, BCryptPasswordEncoder encoder) UserEntity devUser = new UserEntity( Bytes.fromBase64("dXNlcg=="), - "user", // name (username) - "user" // displayName + "user", + "user" ); devUser.setPasswordHash(encoder.encode("1234")); - devUser.setRole("ROLE_USER"); + devUser.setRole(UserRole.USER); devUser.setEmail("devuser@gmail.com"); repository.save(devUser); diff --git a/src/main/java/backendlab/team4you/casefile/CaseFile.java b/src/main/java/backendlab/team4you/casefile/CaseFile.java index 05a25d4..8694625 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFile.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFile.java @@ -1,6 +1,7 @@ package backendlab.team4you.casefile; import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.common.ConfidentialityLevel; import jakarta.persistence.*; import java.time.LocalDateTime; @@ -9,7 +10,11 @@ @Table( name = "case_file", uniqueConstraints = { - @UniqueConstraint(name = "uk_case_file_s3_key", columnNames = "s3_key") + @UniqueConstraint(name = "uk_case_file_s3_key", columnNames = "s3_key"), + @UniqueConstraint(name = "uk_case_file_case_record_document_number", + columnNames = {"case_record_id", "document_number"}), + @UniqueConstraint(name = "uk_case_file_document_reference", + columnNames = "document_reference") } ) public class CaseFile { @@ -37,6 +42,16 @@ public class CaseFile { @Column(name = "uploaded_at", nullable = false) private LocalDateTime uploadedAt; + @Column(name = "document_number", nullable = false) + private int documentNumber; + + @Column(name = "document_reference", nullable = false) + private String documentReference; + + @Enumerated(EnumType.STRING) + @Column(name = "confidentiality_level", nullable = false, length = 50) + private ConfidentialityLevel confidentialityLevel; + public String getS3Key() { return s3Key; } @@ -92,4 +107,32 @@ public CaseRecord getCaseRecord() { public String getOriginalFilename() { return originalFileName; } + + public int getDocumentNumber() { + return documentNumber; + } + + public void setDocumentNumber(int documentNumber) { + this.documentNumber = documentNumber; + } + + public String getDocumentReference() { + return documentReference; + } + + public void setDocumentReference(String documentReference) { + this.documentReference = documentReference; + } + + public ConfidentialityLevel getConfidentialityLevel() { + return confidentialityLevel; + } + + public void setConfidentialityLevel(ConfidentialityLevel confidentialityLevel) { + this.confidentialityLevel = confidentialityLevel; + } + + public boolean isConfidential() { + return confidentialityLevel == ConfidentialityLevel.CONFIDENTIAL; + } } diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileController.java b/src/main/java/backendlab/team4you/casefile/CaseFileController.java index a56e451..7a1542a 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileController.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileController.java @@ -1,16 +1,20 @@ package backendlab.team4you.casefile; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserService; +import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import org.springframework.http.ContentDisposition; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.security.Principal; import java.util.List; @RestController @@ -18,25 +22,34 @@ public class CaseFileController { private final CaseFileService caseFileService; + private final UserService userService; - public CaseFileController(CaseFileService caseFileService) { + public CaseFileController(CaseFileService caseFileService, UserService userService) { this.caseFileService = caseFileService; + this.userService = userService; } @PostMapping public ResponseEntity uploadFile( @PathVariable Long caseRecordId, - @RequestParam("file") MultipartFile file + @RequestParam("file") MultipartFile file, + @RequestParam("confidentialityLevel") ConfidentialityLevel confidentialityLevel, + Principal principal ) throws IOException { - CaseFile savedFile = caseFileService.uploadFile(caseRecordId, file); + UserEntity currentUser = userService.getCurrentUser(principal); + CaseFile savedFile = caseFileService.uploadFile(caseRecordId, file, confidentialityLevel, currentUser); return ResponseEntity.ok(CaseFileResponseDto.from(savedFile)); } @GetMapping - public ResponseEntity > listFiles(@PathVariable Long caseRecordId) { - List files = caseFileService.listFiles(caseRecordId).stream() - .map(CaseFileResponseDto::from) - .toList(); + public ResponseEntity> listFiles( + @PathVariable Long caseRecordId, + Principal principal + ) { + UserEntity currentUser = userService.getCurrentUser(principal); + + List files = + caseFileService.listFileItemsForViewer(caseRecordId, currentUser); return ResponseEntity.ok(files); } @@ -44,19 +57,23 @@ public ResponseEntity > listFiles(@PathVariable Long c @GetMapping("/{fileId}") public ResponseEntity downloadFile( @PathVariable Long caseRecordId, - @PathVariable Long fileId + @PathVariable Long fileId, + Principal principal ) { - CaseFile caseFile = caseFileService.getCaseFile(caseRecordId, fileId); + 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)) { + try (InputStream stream = caseFileService.downloadFile(caseRecordId, fileId, currentUser)) { stream.transferTo(outputStream); } }; + return ResponseEntity.ok() .header( HttpHeaders.CONTENT_DISPOSITION, @@ -72,9 +89,11 @@ public ResponseEntity downloadFile( @DeleteMapping("/{fileId}") public ResponseEntity deleteFile( @PathVariable Long caseRecordId, - @PathVariable Long fileId + @PathVariable Long fileId, + Principal principal ) { - caseFileService.deleteFile(caseRecordId, fileId); + UserEntity currentUser = userService.getCurrentUser(principal); + caseFileService.deleteFile(caseRecordId, fileId, currentUser); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileListItemDto.java b/src/main/java/backendlab/team4you/casefile/CaseFileListItemDto.java new file mode 100644 index 0000000..3d12430 --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/CaseFileListItemDto.java @@ -0,0 +1,10 @@ +package backendlab.team4you.casefile; + +public record CaseFileListItemDto( + Long id, + String documentReference, + String displayName, + boolean confidential, + boolean canDownload +) { +} diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java index 174d115..0691f52 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java @@ -1,11 +1,15 @@ package backendlab.team4you.casefile; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import java.util.List; import java.util.Optional; public interface CaseFileRepository extends JpaRepository { - List findByCaseRecordId(Long caseRecordId); + List findByCaseRecordIdOrderByDocumentNumberAsc(Long caseRecordId); Optional findByIdAndCaseRecordId(Long id, Long caseRecordId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findTopByCaseRecordIdOrderByDocumentNumberDesc(Long caseRecordId); } diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileResponseDto.java b/src/main/java/backendlab/team4you/casefile/CaseFileResponseDto.java index 2e2f0dd..73b991d 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileResponseDto.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileResponseDto.java @@ -1,5 +1,7 @@ package backendlab.team4you.casefile; +import backendlab.team4you.common.ConfidentialityLevel; + import java.time.LocalDateTime; public record CaseFileResponseDto( @@ -7,7 +9,10 @@ public record CaseFileResponseDto( String originalFilename, String contentType, long size, - LocalDateTime uploadedAt + LocalDateTime uploadedAt, + int documentNumber, + String documentReference, + ConfidentialityLevel confidentialityLevel ) { public static CaseFileResponseDto from(CaseFile caseFile) { return new CaseFileResponseDto( @@ -15,7 +20,10 @@ public static CaseFileResponseDto from(CaseFile caseFile) { caseFile.getOriginalFilename(), caseFile.getContentType(), caseFile.getSize(), - caseFile.getUploadedAt() + caseFile.getUploadedAt(), + caseFile.getDocumentNumber(), + caseFile.getDocumentReference(), + caseFile.getConfidentialityLevel() ); } } diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileService.java b/src/main/java/backendlab/team4you/casefile/CaseFileService.java index 65ca9a4..27c1f94 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileService.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileService.java @@ -1,19 +1,26 @@ package backendlab.team4you.casefile; +import backendlab.team4you.casefile.access.CaseFileAccessService; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.common.ConfidentialityLevel; import backendlab.team4you.exceptions.CaseFileNotFoundException; import backendlab.team4you.exceptions.CaseRecordNotFoundException; +import backendlab.team4you.exceptions.FileStorageConfigurationException; +import backendlab.team4you.exceptions.FileTooLargeException; import backendlab.team4you.exceptions.InvalidFileNameException; import backendlab.team4you.s3.S3Service; +import backendlab.team4you.user.UserEntity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.InvalidMediaTypeException; import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; import java.io.IOException; import java.io.InputStream; @@ -29,31 +36,49 @@ public class CaseFileService { private final CaseRecordRepository caseRecordRepository; private final CaseFileRepository caseFileRepository; + private final CaseFileAccessService caseFileAccessService; private final S3Service s3Service; public CaseFileService( CaseRecordRepository caseRecordRepository, CaseFileRepository caseFileRepository, + CaseFileAccessService caseFileAccessService, S3Service s3Service - ) { + ) { this.caseRecordRepository = caseRecordRepository; this.caseFileRepository = caseFileRepository; + this.caseFileAccessService = caseFileAccessService; this.s3Service = s3Service; } @Transactional - public CaseFile uploadFile(Long caseRecordId, MultipartFile file) throws IOException { + public CaseFile uploadFile( + Long caseRecordId, + MultipartFile file, + ConfidentialityLevel confidentialityLevel, + UserEntity actor + ) throws IOException { + + ConfidentialityLevel effectiveConfidentialityLevel = + confidentialityLevel != null ? confidentialityLevel : ConfidentialityLevel.OPEN; + + if (!caseFileAccessService.canUploadFile(actor, caseRecordId, effectiveConfidentialityLevel)) { + throw new AccessDeniedException("Du har inte behörighet att ladda upp denna fil."); + } if (file.getSize() > MAX_FILE_SIZE_BYTES) { - throw new IllegalArgumentException("File exceeds maximum size"); + throw new FileTooLargeException(MAX_FILE_SIZE_BYTES); } - CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) + CaseRecord caseRecord = caseRecordRepository.findByIdWithLock(caseRecordId) .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); + int nextDocumentNumber = allocateNextDocumentNumber(caseRecord.getId()); + String documentReference = caseRecord.getCaseNumber() + "-" + nextDocumentNumber; + String originalFilename = file.getOriginalFilename(); if (originalFilename == null || originalFilename.isBlank()) { - throw new InvalidFileNameException("Filename must not be blank"); + throw new InvalidFileNameException("Filnamn måste anges."); } String contentType = normalizeContentType(file.getContentType()); @@ -72,9 +97,17 @@ public CaseFile uploadFile(Long caseRecordId, MultipartFile file) throws IOExcep caseFile.setContentType(contentType); caseFile.setSize(file.getSize()); caseFile.setUploadedAt(LocalDateTime.now()); + caseFile.setDocumentNumber(nextDocumentNumber); + caseFile.setDocumentReference(documentReference); + caseFile.setConfidentialityLevel(effectiveConfidentialityLevel); return caseFileRepository.saveAndFlush(caseFile); + } catch (NoSuchBucketException exception) { + throw new FileStorageConfigurationException( + "Filuppladdning är inte korrekt konfigurerad: S3-bucket saknas.", + exception + ); } catch (RuntimeException | IOException exception) { if (uploadedToS3) { cleanupUploadedObjectIfPossible(s3Key, exception); @@ -86,7 +119,33 @@ public CaseFile uploadFile(Long caseRecordId, MultipartFile file) throws IOExcep @Transactional(readOnly = true) public List listFiles(Long caseRecordId) { ensureCaseRecordExists(caseRecordId); - return caseFileRepository.findByCaseRecordId(caseRecordId); + return caseFileRepository.findByCaseRecordIdOrderByDocumentNumberAsc(caseRecordId); + } + + @Transactional(readOnly = true) + public List listFileItemsForViewer(Long caseRecordId, UserEntity viewer) { + ensureCaseRecordExists(caseRecordId); + + return caseFileRepository.findByCaseRecordIdOrderByDocumentNumberAsc(caseRecordId).stream() + .map(file -> { + boolean canView = caseFileAccessService.canViewFile(viewer, file); + boolean confidential = file.getConfidentialityLevel() == ConfidentialityLevel.CONFIDENTIAL; + + String displayName = (!confidential || canView) + ? file.getOriginalFilename() + : "Sekretess"; + + boolean canDownload = !confidential || canView; + + return new CaseFileListItemDto( + file.getId(), + file.getDocumentReference(), + displayName, + confidential, + canDownload + ); + }) + .toList(); } @Transactional(readOnly = true) @@ -96,15 +155,30 @@ public CaseFile getCaseFile(Long caseRecordId, Long fileId) { } @Transactional(readOnly = true) - public InputStream downloadFile(Long caseRecordId, Long fileId) { + public CaseFile getCaseFileForViewer(Long caseRecordId, Long fileId, UserEntity viewer) { CaseFile caseFile = getCaseFile(caseRecordId, fileId); + + if (!caseFileAccessService.canViewFile(viewer, caseFile)) { + throw new AccessDeniedException("Du har inte behörighet att öppna denna fil."); + } + + return caseFile; + } + + @Transactional(readOnly = true) + public InputStream downloadFile(Long caseRecordId, Long fileId, UserEntity viewer) { + CaseFile caseFile = getCaseFileForViewer(caseRecordId, fileId, viewer); return s3Service.downloadFile(caseFile.getS3Key()); } @Transactional - public void deleteFile(Long caseRecordId, Long fileId) { + public void deleteFile(Long caseRecordId, Long fileId, UserEntity actor) { CaseFile caseFile = getCaseFile(caseRecordId, fileId); + if (!caseFileAccessService.canDeleteFile(actor, caseFile)) { + throw new AccessDeniedException("Du har inte behörighet att radera denna fil."); + } + String s3Key = caseFile.getS3Key(); caseFileRepository.delete(caseFile); try { @@ -120,7 +194,7 @@ private String normalizeContentType(String contentType) { return MediaType.APPLICATION_OCTET_STREAM_VALUE; } try { - return MediaType.parseMediaType(contentType).toString(); + return MediaType.parseMediaType(contentType).toString(); } catch (InvalidMediaTypeException exception) { return MediaType.APPLICATION_OCTET_STREAM_VALUE; } @@ -153,7 +227,16 @@ private void cleanupUploadedObjectIfPossible(String s3Key, Exception originalExc } if (originalException instanceof DataIntegrityViolationException) { - log.warn("Data integrity violation while persisting CaseFile (possible duplicate s3_key or invalid FK/NULL). Attempted cleanup for key={}", s3Key); + log.warn( + "Data integrity violation while persisting CaseFile (possible duplicate s3_key or invalid FK/NULL). Attempted cleanup for key={}", + s3Key + ); } } + + private int allocateNextDocumentNumber(Long caseRecordId) { + return caseFileRepository.findTopByCaseRecordIdOrderByDocumentNumberDesc(caseRecordId) + .map(caseFile -> caseFile.getDocumentNumber() + 1) + .orElse(1); + } } diff --git a/src/main/java/backendlab/team4you/casefile/access/CaseFileAccess.java b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccess.java new file mode 100644 index 0000000..140c520 --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccess.java @@ -0,0 +1,61 @@ +package backendlab.team4you.casefile.access; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.user.UserEntity; +import jakarta.persistence.*; + +@Entity +@Table( + name = "case_file_access", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_case_file_access_case_user", + columnNames = {"case_record_id", "user_id"} + ) + } +) +public class CaseFileAccess { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "case_record_id", nullable = false) + private CaseRecord caseRecord; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(name = "can_view_confidential_files", nullable = false) + private boolean canViewConfidentialFiles; + + public Long getId() { + return id; + } + + public CaseRecord getCaseRecord() { + return caseRecord; + } + + public void setCaseRecord(CaseRecord caseRecord) { + this.caseRecord = caseRecord; + } + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + + public boolean isCanViewConfidentialFiles() { + return canViewConfidentialFiles; + } + + public void setCanViewConfidentialFiles(boolean canViewConfidentialFiles) { + this.canViewConfidentialFiles = canViewConfidentialFiles; + } +} diff --git a/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessAdminService.java b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessAdminService.java new file mode 100644 index 0000000..65c20c9 --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessAdminService.java @@ -0,0 +1,54 @@ +package backendlab.team4you.casefile.access; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.exceptions.CaseRecordNotFoundException; +import backendlab.team4you.exceptions.UserNotFoundException; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CaseFileAccessAdminService { + + private final CaseFileAccessRepository caseFileAccessRepository; + private final CaseRecordRepository caseRecordRepository; + private final UserRepository userRepository; + + public CaseFileAccessAdminService(CaseFileAccessRepository caseFileAccessRepository, + CaseRecordRepository caseRecordRepository, + UserRepository userRepository) { + this.caseFileAccessRepository = caseFileAccessRepository; + this.caseRecordRepository = caseRecordRepository; + this.userRepository = userRepository; + } + + @Transactional + public void grantConfidentialFileAccess(Long caseRecordId, String userId) { + CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) + .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); + + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("User not found: " + userId)); + + CaseFileAccess access = caseFileAccessRepository + .findByCaseRecordIdAndUserId(caseRecordId, userId) + .orElseGet(CaseFileAccess::new); + + access.setCaseRecord(caseRecord); + access.setUser(user); + access.setCanViewConfidentialFiles(true); + + caseFileAccessRepository.save(access); + } + + @Transactional + public void revokeConfidentialFileAccess(Long caseRecordId, String userId) { + caseFileAccessRepository.findByCaseRecordIdAndUserId(caseRecordId, userId) + .ifPresent(access -> { + access.setCanViewConfidentialFiles(false); + caseFileAccessRepository.save(access); + }); + } +} diff --git a/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessRepository.java b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessRepository.java new file mode 100644 index 0000000..5a64c71 --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessRepository.java @@ -0,0 +1,12 @@ +package backendlab.team4you.casefile.access; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CaseFileAccessRepository extends JpaRepository { + + boolean existsByCaseRecordIdAndUserIdAndCanViewConfidentialFilesTrue(Long caseRecordId, String userId); + + Optional findByCaseRecordIdAndUserId(Long caseRecordId, String userId); +} diff --git a/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessService.java b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessService.java new file mode 100644 index 0000000..2528a2e --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/access/CaseFileAccessService.java @@ -0,0 +1,78 @@ +package backendlab.team4you.casefile.access; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import org.springframework.stereotype.Service; + +@Service +public class CaseFileAccessService { + + private final CaseFileAccessRepository caseFileAccessRepository; + + public CaseFileAccessService(CaseFileAccessRepository caseFileAccessRepository) { + this.caseFileAccessRepository = caseFileAccessRepository; + } + + public boolean canViewFile(UserEntity user, CaseFile caseFile) { + if (caseFile.getConfidentialityLevel() == ConfidentialityLevel.OPEN) { + return true; + } + + if (user == null) { + return false; + } + + if (isAdmin(user)) { + return true; + } + + return hasConfidentialFileAccess(user, caseFile); + } + + public boolean canDeleteFile(UserEntity user, CaseFile caseFile) { + if (user == null) { + return false; + } + + if (isAdmin(user)) { + return true; + } + + return hasConfidentialFileAccess(user, caseFile); + } + + public boolean canUploadFile(UserEntity user, Long caseRecordId, ConfidentialityLevel confidentialityLevel) { + if (user == null) { + return false; + } + + if (isAdmin(user)) { + return true; + } + + ConfidentialityLevel effectiveLevel = + confidentialityLevel != null ? confidentialityLevel : ConfidentialityLevel.OPEN; + + if (effectiveLevel == ConfidentialityLevel.OPEN) { + return true; + } + + return caseFileAccessRepository.existsByCaseRecordIdAndUserIdAndCanViewConfidentialFilesTrue( + caseRecordId, + user.getIdAsString() + ); + } + + private boolean hasConfidentialFileAccess(UserEntity user, CaseFile caseFile) { + return caseFileAccessRepository.existsByCaseRecordIdAndUserIdAndCanViewConfidentialFilesTrue( + caseFile.getCaseRecord().getId(), + user.getIdAsString() + ); + } + + private boolean isAdmin(UserEntity user) { + return user.getRole() == UserRole.ADMIN; + } +} diff --git a/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java b/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java new file mode 100644 index 0000000..64cc4ea --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java @@ -0,0 +1,112 @@ +package backendlab.team4you.casefile.ui; + +import backendlab.team4you.casefile.CaseFileService; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.exceptions.CaseFileNotFoundException; +import backendlab.team4you.exceptions.CaseRecordNotFoundException; +import backendlab.team4you.exceptions.FileStorageConfigurationException; +import backendlab.team4you.exceptions.FileTooLargeException; +import backendlab.team4you.exceptions.InvalidFileNameException; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.security.Principal; + +@Controller +@RequestMapping("/dashboard/case-management") +public class CaseFileViewController { + + private static final Logger log = LoggerFactory.getLogger(CaseFileViewController.class); + + private final CaseFileService caseFileService; + private final UserService userService; + + public CaseFileViewController(CaseFileService caseFileService, UserService userService) { + this.caseFileService = caseFileService; + this.userService = userService; + } + + @GetMapping("/case-records/{caseId}/files") + public String caseFiles(@PathVariable Long caseId, Model model, Principal principal) { + UserEntity currentUser = userService.getCurrentUser(principal); + + model.addAttribute("files", caseFileService.listFileItemsForViewer(caseId, currentUser)); + model.addAttribute("caseRecordId", caseId); + return "fragments/case-management/case-file-list :: caseFileList"; + } + + @PostMapping("/case-records/{caseId}/files") + public String uploadCaseFile( + @PathVariable Long caseId, + @RequestParam("file") MultipartFile file, + @RequestParam("confidentialityLevel") ConfidentialityLevel confidentialityLevel, + Model model, + Principal principal + ) { + UserEntity currentUser = userService.getCurrentUser(principal); + + try { + caseFileService.uploadFile(caseId, file, confidentialityLevel, currentUser); + model.addAttribute("successMessage", "Filen laddades upp."); + } catch (CaseRecordNotFoundException ex) { + model.addAttribute("errorMessage", "Ärendet kunde inte hittas."); + } catch (org.springframework.security.access.AccessDeniedException ex) { + model.addAttribute("errorMessage", "Du har inte behörighet att ladda upp filer här."); + } catch (InvalidFileNameException | FileTooLargeException ex) { + model.addAttribute("errorMessage", ex.getMessage()); + } catch (FileStorageConfigurationException ex) { + log.error("File storage configuration error while uploading file for caseId={}", caseId, ex); + model.addAttribute("errorMessage", "Filhanteringen är tillfälligt otillgänglig."); + } catch (Exception ex) { + log.error("Unexpected error while uploading file for caseId={}", caseId, ex); + model.addAttribute("errorMessage", "Något gick fel vid uppladdning av filen."); + } + + return reloadFileListFragment(caseId, currentUser, model); + } + + @DeleteMapping("/case-records/{caseId}/files/{fileId}") + public String deleteCaseFile( + @PathVariable Long caseId, + @PathVariable Long fileId, + Model model, + Principal principal + ) { + UserEntity currentUser = userService.getCurrentUser(principal); + + try { + caseFileService.deleteFile(caseId, fileId, currentUser); + model.addAttribute("successMessage", "Filen togs bort."); + } catch (CaseRecordNotFoundException ex) { + model.addAttribute("errorMessage", "Ärendet kunde inte hittas."); + } catch (CaseFileNotFoundException ex) { + model.addAttribute("errorMessage", "Filen kunde inte hittas."); + } catch (org.springframework.security.access.AccessDeniedException ex) { + model.addAttribute("errorMessage", "Du har inte behörighet att ta bort den här filen."); + } catch (Exception ex) { + log.error("Unexpected error while deleting fileId={} for caseId={}", fileId, caseId, ex); + model.addAttribute("errorMessage", "Något gick fel när filen skulle tas bort."); + } + + return reloadFileListFragment(caseId, currentUser, model); + } + + private String reloadFileListFragment(Long caseId, UserEntity currentUser, Model model) { + model.addAttribute("caseRecordId", caseId); + + try { + model.addAttribute("files", caseFileService.listFileItemsForViewer(caseId, currentUser)); + } catch (Exception ex) { + log.error("Unexpected error while reloading file list for caseId={}", caseId, ex); + model.addAttribute("files", java.util.List.of()); + } + + return "fragments/case-management/case-file-list :: caseFileList"; + } +} \ No newline at end of file diff --git a/src/main/java/backendlab/team4you/casefile/ui/CaseManagementViewController.java b/src/main/java/backendlab/team4you/casefile/ui/CaseManagementViewController.java new file mode 100644 index 0000000..7b01a10 --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/ui/CaseManagementViewController.java @@ -0,0 +1,20 @@ +package backendlab.team4you.casefile.ui; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/dashboard") +public class CaseManagementViewController { + + @GetMapping("/case-management") + public String caseManagementFragment() { + return "fragments/case-management/page :: content"; + } + + @GetMapping("/case-management-page") + public String caseManagementPage() { + return "dashboard/case-management"; + } +} diff --git a/src/main/java/backendlab/team4you/casefile/ui/CaseRecordViewController.java b/src/main/java/backendlab/team4you/casefile/ui/CaseRecordViewController.java new file mode 100644 index 0000000..c1ddb62 --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/ui/CaseRecordViewController.java @@ -0,0 +1,231 @@ +package backendlab.team4you.casefile.ui; + +import backendlab.team4you.caserecord.CaseRecordRequestDto; +import backendlab.team4you.caserecord.CaseRecordService; +import backendlab.team4you.caserecord.CaseStatus; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.exceptions.CaseRecordNotFoundException; +import backendlab.team4you.exceptions.RegistryNotFoundException; +import backendlab.team4you.exceptions.UserNotFoundException; +import backendlab.team4you.registry.RegistryService; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; + +@Controller +@RequestMapping("/dashboard/case-management") +public class CaseRecordViewController { + + private static final Logger log = LoggerFactory.getLogger(CaseRecordViewController.class); + + private final RegistryService registryService; + private final CaseRecordService caseRecordService; + private final UserService userService; + + public CaseRecordViewController( + RegistryService registryService, + CaseRecordService caseRecordService, + UserService userService + ) { + this.registryService = registryService; + this.caseRecordService = caseRecordService; + this.userService = userService; + } + + @GetMapping("/registries/{registryId}/case-records") + public String caseRecords(@PathVariable Long registryId, Model model, Principal principal) { + UserEntity currentUser = userService.getCurrentUser(principal); + return reloadCaseRecordListFragment(registryId, model, currentUser); + } + + @PostMapping("/registries/{registryId}/case-records") + public String createCaseRecord( + @PathVariable Long registryId, + @RequestParam String title, + @RequestParam(required = false) String description, + @RequestParam CaseStatus status, + @RequestParam(required = false) String assignedUserId, + @RequestParam ConfidentialityLevel confidentialityLevel, + Model model, + Principal principal + ) { + UserEntity currentUser = userService.getCurrentUser(principal); + + try { + CaseRecordRequestDto requestDto = new CaseRecordRequestDto( + registryId, + title, + description, + status, + currentUser.getId().toBase64UrlString(), + normalizeAssignedUserId(assignedUserId), + confidentialityLevel, + null + ); + + caseRecordService.createCaseRecord(requestDto); + model.addAttribute("successMessage", "ärende skapat."); + } catch (RegistryNotFoundException exception) { + model.addAttribute("errorMessage", "Registriet kunde inte hittas."); + return buildMissingRegistryFragment(registryId, model, currentUser); + } catch (UserNotFoundException | IllegalArgumentException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } catch (Exception exception) { + log.error("Unexpected error while creating case record for registryId={}", registryId, exception); + model.addAttribute("errorMessage", "Något gick fel när ärendet skulle skapas."); + } + + return reloadCaseRecordListFragment(registryId, model, currentUser); + } + + @GetMapping("/case-records/{caseId}") + public String caseRecordDetail(@PathVariable Long caseId, Model model) { + try { + populateCaseRecordDetailModel(caseId, model); + } catch (CaseRecordNotFoundException exception) { + model.addAttribute("errorMessage", "Ärendet kunde inte hittas."); + return buildMissingCaseRecordDetailFragment(caseId, model); + } catch (Exception exception) { + log.error("Unexpected error while loading case record detail for caseId={}", caseId, exception); + model.addAttribute("errorMessage", "Något gick fel när ärendet skulle laddas."); + return buildMissingCaseRecordDetailFragment(caseId, model); + } + + return "fragments/case-management/case-record-detail :: caseRecordDetail"; + } + + @PostMapping("/case-records/{caseId}/update") + public String updateCaseRecord( + @PathVariable Long caseId, + @RequestParam CaseStatus status, + @RequestParam(required = false) String assignedUserId, + Model model + ) { + try { + caseRecordService.updateCaseRecord(caseId, status, normalizeAssignedUserId(assignedUserId)); + model.addAttribute("successMessage", "ändringarna sparades."); + } catch (CaseRecordNotFoundException exception) { + model.addAttribute("errorMessage", "Ärendet kunde inte hittas."); + return buildMissingCaseRecordDetailFragment(caseId, model); + } catch (UserNotFoundException | IllegalArgumentException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } catch (Exception exception) { + log.error("Unexpected error while updating case record caseId={}", caseId, exception); + model.addAttribute("errorMessage", "Något gick fel när ärendet skulle uppdateras."); + } + + return reloadCaseRecordDetailFragment(caseId, model); + } + + private String reloadCaseRecordListFragment(Long registryId, Model model, UserEntity currentUser) { + try { + populateCaseRecordPanelModel(registryId, model, currentUser); + } catch (RegistryNotFoundException exception) { + model.addAttribute("errorMessage", "Registret kunde inte hittas."); + return buildMissingRegistryFragment(registryId, model, currentUser); + } catch (Exception exception) { + log.error("Unexpected error while reloading case record list for registryId={}", registryId, exception); + model.addAttribute("errorMessage", "Något gick fel när ärendelistan skulle laddas."); + return buildFallbackCaseRecordListFragment(registryId, model, currentUser); + } + + return "fragments/case-management/case-record-list :: caseRecordList"; + } + + private String reloadCaseRecordDetailFragment(Long caseId, Model model) { + try { + populateCaseRecordDetailModel(caseId, model); + } catch (CaseRecordNotFoundException exception) { + model.addAttribute("errorMessage", "Ärendet kunde inte hittas."); + return buildMissingCaseRecordDetailFragment(caseId, model); + } catch (Exception exception) { + log.error("Unexpected error while reloading case record detail for caseId={}", caseId, exception); + model.addAttribute("errorMessage", "Något gick fel när ärendedetaljer skulle laddas."); + return buildMissingCaseRecordDetailFragment(caseId, model); + } + + return "fragments/case-management/case-record-detail :: caseRecordDetail"; + } + + private String buildMissingRegistryFragment(Long registryId, Model model, UserEntity currentUser) { + model.addAttribute("registryId", registryId); + model.addAttribute("registryName", "okänt register"); + model.addAttribute("caseRecords", List.of()); + model.addAttribute("currentUserDisplayName", buildDisplayName(currentUser)); + model.addAttribute("assignableUsers", buildAssignableUserOptions()); + return "fragments/case-management/case-record-list :: caseRecordList"; + } + + private String buildFallbackCaseRecordListFragment(Long registryId, Model model, UserEntity currentUser) { + model.addAttribute("registryId", registryId); + model.addAttribute("registryName", "ärenden"); + model.addAttribute("caseRecords", List.of()); + model.addAttribute("currentUserDisplayName", buildDisplayName(currentUser)); + model.addAttribute("assignableUsers", buildAssignableUserOptions()); + return "fragments/case-management/case-record-list :: caseRecordList"; + } + + private String buildMissingCaseRecordDetailFragment(Long caseId, Model model) { + model.addAttribute("caseRecordId", caseId); + model.addAttribute("caseRecord", null); + model.addAttribute("assignableUsers", buildAssignableUserOptions()); + return "fragments/case-management/case-record-detail :: caseRecordDetail"; + } + + private void populateCaseRecordPanelModel(Long registryId, Model model, UserEntity currentUser) { + model.addAttribute("registryId", registryId); + model.addAttribute("registryName", registryService.findById(registryId).name()); + model.addAttribute("caseRecords", caseRecordService.findByRegistryId(registryId)); + model.addAttribute("currentUserDisplayName", buildDisplayName(currentUser)); + model.addAttribute("assignableUsers", buildAssignableUserOptions()); + } + + private void populateCaseRecordDetailModel(Long caseId, Model model) { + model.addAttribute("caseRecord", caseRecordService.findById(caseId)); + model.addAttribute("caseRecordId", caseId); + model.addAttribute("assignableUsers", buildAssignableUserOptions()); + } + + private List buildAssignableUserOptions() { + return userService.findAll().stream() + .map(user -> new AssignableUserOption( + user.getId().toBase64UrlString(), + buildDisplayName(user) + )) + .toList(); + } + + private String normalizeAssignedUserId(String assignedUserId) { + if (assignedUserId == null || assignedUserId.isBlank()) { + return null; + } + return assignedUserId; + } + + private String buildDisplayName(UserEntity user) { + String firstName = user.getFirstName() != null ? user.getFirstName().trim() : ""; + String lastName = user.getLastName() != null ? user.getLastName().trim() : ""; + + String fullName = (firstName + " " + lastName).trim(); + if (!fullName.isBlank()) { + return fullName; + } + + if (user.getDisplayName() != null && !user.getDisplayName().isBlank()) { + return user.getDisplayName(); + } + + return user.getName(); + } + + private record AssignableUserOption(String id, String displayName) { + } + +} diff --git a/src/main/java/backendlab/team4you/casefile/ui/RegistryViewController.java b/src/main/java/backendlab/team4you/casefile/ui/RegistryViewController.java new file mode 100644 index 0000000..f40cf0e --- /dev/null +++ b/src/main/java/backendlab/team4you/casefile/ui/RegistryViewController.java @@ -0,0 +1,45 @@ +package backendlab.team4you.casefile.ui; + +import backendlab.team4you.exceptions.DuplicateRegistryCodeException; +import backendlab.team4you.exceptions.DuplicateRegistryNameException; +import backendlab.team4you.registry.RegistryRequestDto; +import backendlab.team4you.registry.RegistryService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping("/dashboard/case-management") +public class RegistryViewController { + + private final RegistryService registryService; + + public RegistryViewController(RegistryService registryService) { + this.registryService = registryService; + } + + @GetMapping("/registries") + public String registries(Model model) { + model.addAttribute("registries", registryService.findAll()); + return "fragments/case-management/registry-list :: registryList"; + } + + @PostMapping("/registries") + public String createRegistry( + @RequestParam String name, + @RequestParam String code, + Model model + ) { + try { + registryService.createRegistry(new RegistryRequestDto(name.trim(), code.trim())); + model.addAttribute("successMessage", "registry skapad."); + } catch (DuplicateRegistryNameException + | DuplicateRegistryCodeException + | IllegalArgumentException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + model.addAttribute("registries", registryService.findAll()); + return "fragments/case-management/registry-list :: registryList"; + } +} diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecord.java b/src/main/java/backendlab/team4you/caserecord/CaseRecord.java index ddd3cb0..d67d633 100644 --- a/src/main/java/backendlab/team4you/caserecord/CaseRecord.java +++ b/src/main/java/backendlab/team4you/caserecord/CaseRecord.java @@ -1,6 +1,7 @@ package backendlab.team4you.caserecord; import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.common.ConfidentialityLevel; import backendlab.team4you.registry.Registry; import backendlab.team4you.user.UserEntity; import jakarta.persistence.*; @@ -36,19 +37,21 @@ public class CaseRecord { @Column(columnDefinition = "text") private String description; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 50) - private String status; + private CaseStatus status; @ManyToOne(optional = false, fetch = FetchType.LAZY) @JoinColumn(name = "owner_user_id", nullable = false) private UserEntity owner; - @ManyToOne(optional = false, fetch = FetchType.LAZY) - @JoinColumn(name = "assigned_user_id", nullable = false) + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "assigned_user_id", nullable = true) private UserEntity assignedUser; + @Enumerated(EnumType.STRING) @Column(name = "confidentiality_level", nullable = false, length = 50) - private String confidentialityLevel = "OPEN"; + private ConfidentialityLevel confidentialityLevel = ConfidentialityLevel.OPEN; @Column(name = "opened_at", nullable = false) private LocalDateTime openedAt; @@ -72,10 +75,10 @@ public CaseRecord( Registry registry, String title, String description, - String status, + CaseStatus status, UserEntity owner, UserEntity assignedUser, - String confidentialityLevel, + ConfidentialityLevel confidentialityLevel, LocalDateTime openedAt ) { this.registry = Objects.requireNonNull(registry, "registry is required"); @@ -84,14 +87,11 @@ public CaseRecord( } this.title = title.trim(); this.description = description; - this.status = (status == null || status.isBlank()) ? "OPEN" : status.trim(); + this.status = Objects.requireNonNull(status, "status is required"); this.owner = Objects.requireNonNull(owner, "owner is required"); - this.assignedUser = Objects.requireNonNull(assignedUser, "assignedUser is required"); + this.assignedUser = assignedUser; this.confidentialityLevel = - (confidentialityLevel == null || confidentialityLevel.isBlank()) - ? "OPEN" - : confidentialityLevel.trim(); - + confidentialityLevel != null ? confidentialityLevel : ConfidentialityLevel.OPEN; this.openedAt = openedAt; } @@ -145,10 +145,14 @@ public String getDescription() { return description; } - public String getStatus() { + public CaseStatus getStatus() { return status; } + public void setStatus(CaseStatus status) { + this.status = Objects.requireNonNull(status, "status is required"); + } + public UserEntity getOwner() { return owner; } @@ -157,7 +161,7 @@ public UserEntity getAssignedUser() { return assignedUser; } - public String getConfidentialityLevel() { + public ConfidentialityLevel getConfidentialityLevel() { return confidentialityLevel; } @@ -180,4 +184,10 @@ public LocalDateTime getClosedAt() { public void setId(long l) { this.id = l; } + + public void setAssignedUser(UserEntity assignedUser) { + this.assignedUser = assignedUser; + } + + } diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java b/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java index 72ac72b..cdce7c3 100644 --- a/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java +++ b/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java @@ -1,12 +1,19 @@ package backendlab.team4you.caserecord; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.Optional; public interface CaseRecordRepository extends JpaRepository { Optional findByCaseNumber(String caseNumber); - + List findByRegistryIdOrderByCreatedAtDesc(Long registryId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from CaseRecord c where c.id = :id") + Optional findByIdWithLock(Long id); boolean existsByCaseNumber(String caseNumber); } diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecordRequestDto.java b/src/main/java/backendlab/team4you/caserecord/CaseRecordRequestDto.java index aa89c92..21d858d 100644 --- a/src/main/java/backendlab/team4you/caserecord/CaseRecordRequestDto.java +++ b/src/main/java/backendlab/team4you/caserecord/CaseRecordRequestDto.java @@ -1,5 +1,6 @@ package backendlab.team4you.caserecord; +import backendlab.team4you.common.ConfidentialityLevel; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -8,27 +9,26 @@ public record CaseRecordRequestDto( - @NotNull(message = "registryId is required") - Long registryId, + @NotNull(message = "registryId is required") + Long registryId, - @NotBlank(message = "title is required") - @Size(max = 255, message = "title must be at most 255 characters") - String title, + @NotBlank(message = "title is required") + @Size(max = 255, message = "title must be at most 255 characters") + String title, - String description, + String description, - @Size(max = 50, message = "status must be at most 50 characters") - String status, + @NotNull(message = "status is required") + CaseStatus status, - @NotBlank(message = "ownerUserId is required") - String ownerUserId, + @NotBlank(message = "ownerUserId is required") + String ownerUserId, - @NotBlank(message = "assignedUserId is required") - String assignedUserId, + String assignedUserId, - @Size(max = 50, message = "confidentiality level must be at most 50 characters") - String confidentialityLevel, + @NotNull(message = "confidentiality level is required") + ConfidentialityLevel confidentialityLevel, - LocalDateTime openedAt -){ -} + LocalDateTime openedAt +) { +} \ No newline at end of file diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecordResponseDto.java b/src/main/java/backendlab/team4you/caserecord/CaseRecordResponseDto.java index c8c0542..9546810 100644 --- a/src/main/java/backendlab/team4you/caserecord/CaseRecordResponseDto.java +++ b/src/main/java/backendlab/team4you/caserecord/CaseRecordResponseDto.java @@ -1,5 +1,7 @@ package backendlab.team4you.caserecord; +import backendlab.team4you.common.ConfidentialityLevel; + import java.time.LocalDateTime; public record CaseRecordResponseDto( @@ -9,10 +11,10 @@ public record CaseRecordResponseDto( String registryCode, String title, String description, - String status, + CaseStatus status, String ownerUserId, String assignedUserId, - String confidentialityLevel, + ConfidentialityLevel confidentialityLevel, LocalDateTime openedAt, LocalDateTime createdAt, LocalDateTime updatedAt, diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecordService.java b/src/main/java/backendlab/team4you/caserecord/CaseRecordService.java index b75bbc7..bf65ed3 100644 --- a/src/main/java/backendlab/team4you/caserecord/CaseRecordService.java +++ b/src/main/java/backendlab/team4you/caserecord/CaseRecordService.java @@ -1,5 +1,6 @@ package backendlab.team4you.caserecord; +import backendlab.team4you.exceptions.CaseRecordNotFoundException; import backendlab.team4you.exceptions.RegistryNotFoundException; import backendlab.team4you.exceptions.UserNotFoundException; import backendlab.team4you.registry.Registry; @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.util.List; @Service @Transactional @@ -39,8 +41,11 @@ public CaseRecordResponseDto createCaseRecord(CaseRecordRequestDto requestDto) { UserEntity owner = userRepository.findById(requestDto.ownerUserId()) .orElseThrow(() -> new UserNotFoundException("user not found: " + requestDto.ownerUserId())); - UserEntity assignedUser = userRepository.findById(requestDto.assignedUserId()) - .orElseThrow(() -> new UserNotFoundException("user not found: " + requestDto.assignedUserId())); + UserEntity assignedUser = null; + if (requestDto.assignedUserId() != null && !requestDto.assignedUserId().isBlank()) { + assignedUser = userRepository.findById(requestDto.assignedUserId()) + .orElseThrow(() -> new UserNotFoundException("user not found: " + requestDto.assignedUserId())); + } CaseRecord caseRecord = new CaseRecord( registry, @@ -114,7 +119,7 @@ private CaseRecordResponseDto toResponseDto(CaseRecord caseRecord) { caseRecord.getDescription(), caseRecord.getStatus(), caseRecord.getOwner().getIdAsString(), - caseRecord.getAssignedUser().getIdAsString(), + caseRecord.getAssignedUser() != null ? caseRecord.getAssignedUser().getIdAsString() : null, caseRecord.getConfidentialityLevel(), caseRecord.getOpenedAt(), caseRecord.getCreatedAt(), @@ -122,4 +127,55 @@ private CaseRecordResponseDto toResponseDto(CaseRecord caseRecord) { caseRecord.getClosedAt() ); } + + @Transactional(readOnly = true) + public List findByRegistryId(Long registryId) { + Registry registry = registryRepository.findById(registryId) + .orElseThrow(() -> new RegistryNotFoundException("registry not found: " + registryId)); + + return caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(registry.getId()).stream() + .map(this::toResponseDto) + .toList(); + } + + @Transactional(readOnly = true) + public CaseRecordResponseDto findById(Long caseRecordId) { + CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) + .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); + + return toResponseDto(caseRecord); + } + + public CaseRecordResponseDto updateCaseRecord(Long caseRecordId, CaseStatus status, String assignedUserId) { + CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) + .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); + + caseRecord.setStatus(status); + + if (assignedUserId == null || assignedUserId.isBlank()) { + caseRecord.setAssignedUser(null); + } else { + UserEntity assignedUser = userRepository.findById(assignedUserId) + .orElseThrow(() -> new UserNotFoundException("user not found: " + assignedUserId)); + caseRecord.setAssignedUser(assignedUser); + } + + CaseRecord savedCaseRecord = caseRecordRepository.save(caseRecord); + return toResponseDto(savedCaseRecord); + } + + private String normalizeStatus(String status) { + if (status == null || status.isBlank()) { + throw new IllegalArgumentException("status is required"); + } + + String normalizedStatus = status.trim().toUpperCase(); + + if (!normalizedStatus.equals("OPEN") + && !normalizedStatus.equals("CLOSED")) { + throw new IllegalArgumentException("invalid status: " + status); + } + + return normalizedStatus; + } } diff --git a/src/main/java/backendlab/team4you/caserecord/CaseStatus.java b/src/main/java/backendlab/team4you/caserecord/CaseStatus.java new file mode 100644 index 0000000..ab82c22 --- /dev/null +++ b/src/main/java/backendlab/team4you/caserecord/CaseStatus.java @@ -0,0 +1,6 @@ +package backendlab.team4you.caserecord; + +public enum CaseStatus { + OPEN, + CLOSED +} diff --git a/src/main/java/backendlab/team4you/common/ConfidentialityLevel.java b/src/main/java/backendlab/team4you/common/ConfidentialityLevel.java new file mode 100644 index 0000000..8b7c451 --- /dev/null +++ b/src/main/java/backendlab/team4you/common/ConfidentialityLevel.java @@ -0,0 +1,6 @@ +package backendlab.team4you.common; + +public enum ConfidentialityLevel { + OPEN, + CONFIDENTIAL +} diff --git a/src/main/java/backendlab/team4you/config/SecurityConfig.java b/src/main/java/backendlab/team4you/config/SecurityConfig.java index 6db59ab..e369cbd 100644 --- a/src/main/java/backendlab/team4you/config/SecurityConfig.java +++ b/src/main/java/backendlab/team4you/config/SecurityConfig.java @@ -44,7 +44,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, .requestMatchers("/webauthn/**").hasAnyRole("USER", ADMIN) .requestMatchers("/admin/**").hasRole(ADMIN) - .requestMatchers("/home", "/profile/**").hasRole("USER") + .requestMatchers("/home", "/profile/**").hasAnyRole("USER", ADMIN) .requestMatchers("/add-passkey", "/webauthn/register/**").hasAnyRole("USER", ADMIN) .anyRequest().authenticated() @@ -81,11 +81,14 @@ public UserDetailsService userDetailsService(UserService userService){ if (user == null) { throw new UsernameNotFoundException("User not found: " + username); } + if (user.getRole() == null) { + throw new UsernameNotFoundException("User has no role assigned: " + username); + } return User.builder() .username(user.getName()) .password(user.getPasswordHash()) - .authorities(user.getRole()) + .roles(user.getRole().name()) .accountLocked(false) .build(); }; diff --git a/src/main/java/backendlab/team4you/controller/SignupController.java b/src/main/java/backendlab/team4you/controller/SignupController.java index 70fe29c..273294b 100644 --- a/src/main/java/backendlab/team4you/controller/SignupController.java +++ b/src/main/java/backendlab/team4you/controller/SignupController.java @@ -58,7 +58,10 @@ public void signup(@RequestBody SignupRequest req, HttpServletRequest request, H ); Authentication auth = new UsernamePasswordAuthenticationToken( - userEntity.getName(), null, List.of(new SimpleGrantedAuthority(userEntity.getRole()))); + userEntity.getName(), + null, + List.of(new SimpleGrantedAuthority("ROLE_" + userEntity.getRole().name())) + ); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(auth); diff --git a/src/main/java/backendlab/team4you/exceptions/ApiExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/ApiExceptionHandler.java deleted file mode 100644 index a1950c2..0000000 --- a/src/main/java/backendlab/team4you/exceptions/ApiExceptionHandler.java +++ /dev/null @@ -1,181 +0,0 @@ -package backendlab.team4you.exceptions; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.time.LocalDateTime; -import java.util.LinkedHashMap; -import java.util.Map; - -@RestControllerAdvice -public class ApiExceptionHandler { - - private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class); - - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException exception) { - log.warn("Bad request: {}", exception.getMessage()); - - return buildErrorResponse( - HttpStatus.BAD_REQUEST, - "bad request", - exception.getMessage() - ); - } - - @ExceptionHandler(IllegalStateException.class) - public ResponseEntity> handleIllegalStateException(IllegalStateException exception) { - log.warn("Invalid application state: {}", exception.getMessage()); - - return buildErrorResponse( - HttpStatus.BAD_REQUEST, - "bad request", - exception.getMessage() - ); - } - - @ExceptionHandler(RegistryNotFoundException.class) - public ResponseEntity> handleRegistryNotFound(RegistryNotFoundException exception) { - log.warn("Registry not found: {}", exception.getMessage()); - - return buildErrorResponse( - HttpStatus.NOT_FOUND, - "not found", - exception.getMessage() - ); - } - - @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity> handleUserNotFound(UserNotFoundException exception) { - log.warn("User not found: {}", exception.getMessage()); - - return buildErrorResponse( - HttpStatus.NOT_FOUND, - "not found", - exception.getMessage() - ); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException exception) { - Map validationErrors = new LinkedHashMap<>(); - - for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) { - validationErrors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage()); - } - - log.warn("Validation failed: {}", validationErrors); - - Map body = new LinkedHashMap<>(); - body.put("timestamp", LocalDateTime.now()); - body.put("status", HttpStatus.BAD_REQUEST.value()); - body.put("error", "validation failed"); - body.put("message", "request validation failed"); - body.put("messages", validationErrors); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(body); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGenericException(Exception exception) { - log.error("Unhandled exception in API layer", exception); - - return buildErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR, - "internal server error", - "an unexpected error occurred" - ); - } - - private ResponseEntity> buildErrorResponse( - HttpStatus status, - String error, - String message - ) { - Map body = new LinkedHashMap<>(); - body.put("timestamp", LocalDateTime.now()); - body.put("status", status.value()); - body.put("error", error); - body.put("message", message); - - return ResponseEntity - .status(status) - .body(body); - } - - @ExceptionHandler(DuplicateRegistryNameException.class) - public ResponseEntity> handleDuplicateRegistryName(DuplicateRegistryNameException exception) { - log.warn("Duplicate registry name: {}", exception.getMessage()); - - return buildErrorResponse( - HttpStatus.CONFLICT, - "conflict", - exception.getMessage() - ); - } - - @ExceptionHandler(DuplicateRegistryCodeException.class) - public ResponseEntity> handleDuplicateRegistryCode(DuplicateRegistryCodeException exception) { - log.warn("Duplicate registry code: {}", exception.getMessage()); - - return buildErrorResponse( - HttpStatus.CONFLICT, - "conflict", - exception.getMessage() - ); - } - - @ExceptionHandler(InvalidFileNameException.class) - public ResponseEntity handleInvalidFileName(InvalidFileNameException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponseDto( - HttpStatus.BAD_REQUEST.value(), - "bad request", - ex.getMessage(), - LocalDateTime.now() - )); - } - - @ExceptionHandler(CaseRecordNotFoundException.class) - public ResponseEntity handleCaseRecordNotFoundException(CaseRecordNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDto( - HttpStatus.NOT_FOUND.value(), - "not found", - ex.getMessage(), - LocalDateTime.now() - )); - } - - @ExceptionHandler(CaseFileNotFoundException.class) - public ResponseEntity handleCaseFileNotFoundException(CaseFileNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDto( - HttpStatus.NOT_FOUND.value(), - "not found", - ex.getMessage(), - LocalDateTime.now() - )); - } - - @ExceptionHandler(FileKeyConflictException.class) - public ResponseEntity handleFileKeyConflictException(FileKeyConflictException ex) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(new ErrorResponseDto( - HttpStatus.CONFLICT.value(), - "conflict", - ex.getMessage(), - LocalDateTime.now() - )); - } - - -} diff --git a/src/main/java/backendlab/team4you/exceptions/FileStorageConfigurationException.java b/src/main/java/backendlab/team4you/exceptions/FileStorageConfigurationException.java new file mode 100644 index 0000000..afd1d4b --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/FileStorageConfigurationException.java @@ -0,0 +1,8 @@ +package backendlab.team4you.exceptions; + +public class FileStorageConfigurationException extends RuntimeException { + + public FileStorageConfigurationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/FileTooLargeException.java b/src/main/java/backendlab/team4you/exceptions/FileTooLargeException.java new file mode 100644 index 0000000..81b582b --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/FileTooLargeException.java @@ -0,0 +1,11 @@ +package backendlab.team4you.exceptions; + +public class FileTooLargeException extends RuntimeException { + public FileTooLargeException(long maxBytes) { + super("Filen är för stor. Maxstorlek är " + toMegabytesRoundedUp(maxBytes) + " MB."); + } + private static long toMegabytesRoundedUp(long bytes) { + long bytesPerMegabyte = 1024L * 1024L; + return Math.max(1L, (bytes + bytesPerMegabyte - 1L) / bytesPerMegabyte); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalExceptionHandler.java deleted file mode 100644 index 40d181d..0000000 --- a/src/main/java/backendlab/team4you/exceptions/GlobalExceptionHandler.java +++ /dev/null @@ -1,51 +0,0 @@ -package backendlab.team4you.exceptions; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; - -import java.time.LocalDateTime; - -@ControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(UserNotFoundException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public String notFoundException(UserNotFoundException ex, Model model) { - - model.addAttribute("errorMessage", ex.getMessage()); - - return "error"; - } - @ExceptionHandler(DuplicateEmailException.class) - @ResponseStatus(HttpStatus.CONFLICT) - public String handleDuplicateEmail(DuplicateEmailException ex, Model model) { - model.addAttribute("errorMessage", ex.getMessage()); - return "error"; - } - - @ExceptionHandler(CaseFileNotFoundException.class) - public ResponseEntity handleCaseFileNotFound(CaseFileNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDto( - HttpStatus.NOT_FOUND.value(), - "Not Found", - ex.getMessage(), - LocalDateTime.now() - )); - } - - @ExceptionHandler(InvalidFileNameException.class) - public ResponseEntity handleInvalidFile(InvalidFileNameException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponseDto( - HttpStatus.BAD_REQUEST.value(), - "Bad Request", - ex.getMessage(), - LocalDateTime.now() - )); - } -} diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java new file mode 100644 index 0000000..6e3530e --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java @@ -0,0 +1,202 @@ +package backendlab.team4you.exceptions; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.io.IOException; +import java.time.LocalDateTime; + +@RestControllerAdvice(basePackages = { + "backendlab.team4you.casefile", + "backendlab.team4you.caserecord", + "backendlab.team4you.registry", + "backendlab.team4you.user", + "backendlab.team4you.controller" +}) +public class GlobalRestExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalRestExceptionHandler.class); + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponseDto( + HttpStatus.FORBIDDEN.value(), + "forbidden", + ex.getMessage(), + LocalDateTime.now() + )); + } + + @ExceptionHandler({ + CaseFileNotFoundException.class, + CaseRecordNotFoundException.class, + RegistryNotFoundException.class, + UserNotFoundException.class + }) + public ResponseEntity handleNotFound(RuntimeException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponseDto( + HttpStatus.NOT_FOUND.value(), + "not found", + ex.getMessage(), + LocalDateTime.now() + )); + } + + @ExceptionHandler({ + InvalidFileNameException.class, + IllegalArgumentException.class, + IllegalStateException.class + }) + public ResponseEntity handleBadRequest(RuntimeException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponseDto( + HttpStatus.BAD_REQUEST.value(), + "bad request", + ex.getMessage(), + LocalDateTime.now() + )); + } + + @ExceptionHandler(FileTooLargeException.class) + public ResponseEntity handleFileTooLarge(FileTooLargeException ex) { + return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE) + .body(new ErrorResponseDto( + HttpStatus.CONTENT_TOO_LARGE.value(), + "content too large", + ex.getMessage(), + LocalDateTime.now() + )); + } + + @ExceptionHandler({ + DuplicateRegistryNameException.class, + DuplicateRegistryCodeException.class, + DuplicateEmailException.class, + FileKeyConflictException.class + }) + public ResponseEntity handleConflict(RuntimeException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponseDto( + HttpStatus.CONFLICT.value(), + "conflict", + ex.getMessage(), + LocalDateTime.now() + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponseDto( + HttpStatus.BAD_REQUEST.value(), + "bad request", + "Ogiltiga indata.", + LocalDateTime.now() + )); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponseDto( + HttpStatus.BAD_REQUEST.value(), + "bad request", + "Begäran kunde inte tolkas.", + LocalDateTime.now() + )); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameter(MissingServletRequestParameterException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponseDto( + HttpStatus.BAD_REQUEST.value(), + "bad request", + "Obligatorisk parameter saknas: " + ex.getParameterName(), + LocalDateTime.now() + )); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException ex) { + String message = "Ogiltigt värde för parameter: " + ex.getName(); + + if (ex.getRequiredType() != null && ex.getRequiredType().isEnum()) { + message = "Ogiltigt värde för parameter '" + ex.getName() + "'."; + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponseDto( + HttpStatus.BAD_REQUEST.value(), + "bad request", + message, + LocalDateTime.now() + )); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(new ErrorResponseDto( + HttpStatus.METHOD_NOT_ALLOWED.value(), + "method not allowed", + "HTTP-metoden stöds inte för denna endpoint.", + LocalDateTime.now() + )); + } + + @ExceptionHandler(FileStorageConfigurationException.class) + public ResponseEntity handleFileStorageConfiguration(FileStorageConfigurationException ex) { + log.error("File storage configuration error", ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponseDto( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "internal server error", + "Filhanteringen är tillfälligt otillgänglig.", + LocalDateTime.now() + )); + } + + @ExceptionHandler({ + IOException.class, + DataIntegrityViolationException.class + }) + public ResponseEntity handleTechnicalExceptions(Exception ex) { + log.error("Technical error in REST flow", ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponseDto( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "internal server error", + "Ett internt fel uppstod.", + LocalDateTime.now() + )); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex) { + log.error("Unexpected REST error", ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponseDto( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "internal server error", + "Ett oväntat fel uppstod.", + LocalDateTime.now() + )); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java new file mode 100644 index 0000000..c59466a --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java @@ -0,0 +1,64 @@ +package backendlab.team4you.exceptions; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice(basePackages = "backendlab.team4you.casefile.ui") +public class GlobalViewExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalViewExceptionHandler.class); + + @ExceptionHandler(UserNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public String handleUserNotFound(UserNotFoundException ex, Model model) { + model.addAttribute("errorMessage", ex.getMessage()); + return "error"; + } + + @ExceptionHandler(DuplicateEmailException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public String handleDuplicateEmail(DuplicateEmailException ex, Model model) { + model.addAttribute("errorMessage", ex.getMessage()); + return "error"; + } + + @ExceptionHandler({ + CaseRecordNotFoundException.class, + RegistryNotFoundException.class, + CaseFileNotFoundException.class + }) + @ResponseStatus(HttpStatus.NOT_FOUND) + public String handleNotFound(RuntimeException ex, Model model) { + model.addAttribute("errorMessage", ex.getMessage()); + return "error"; + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public String handleAccessDenied(AccessDeniedException ex, Model model) { + model.addAttribute("errorMessage", "Du har inte behörighet att utföra den här åtgärden."); + return "error"; + } + + @ExceptionHandler(FileStorageConfigurationException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public String handleStorageConfiguration(FileStorageConfigurationException ex, Model model) { + log.error("File storage configuration error in view flow", ex); + model.addAttribute("errorMessage", "Filhanteringen är tillfälligt otillgänglig."); + return "error"; + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public String handleUnexpected(Exception ex, Model model) { + log.error("Unexpected view error", ex); + model.addAttribute("errorMessage", "Något gick fel. Försök igen."); + return "error"; + } +} diff --git a/src/main/java/backendlab/team4you/registry/RegistryService.java b/src/main/java/backendlab/team4you/registry/RegistryService.java index 665a8c2..e00ea21 100644 --- a/src/main/java/backendlab/team4you/registry/RegistryService.java +++ b/src/main/java/backendlab/team4you/registry/RegistryService.java @@ -2,10 +2,13 @@ import backendlab.team4you.exceptions.DuplicateRegistryCodeException; import backendlab.team4you.exceptions.DuplicateRegistryNameException; +import backendlab.team4you.exceptions.RegistryNotFoundException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @Transactional public class RegistryService { @@ -48,4 +51,27 @@ public RegistryResponseDto createRegistry(RegistryRequestDto requestDto) { throw e; } } + + @Transactional(readOnly = true) + public List findAll() { + return registryRepository.findAll().stream() + .map(registry -> new RegistryResponseDto( + registry.getId(), + registry.getName(), + registry.getCode() + )) + .toList(); + } + + @Transactional(readOnly = true) + public RegistryResponseDto findById(Long registryId) { + Registry registry = registryRepository.findById(registryId) + .orElseThrow(() -> new RegistryNotFoundException("registry not found: " + registryId)); + + return new RegistryResponseDto( + registry.getId(), + registry.getName(), + registry.getCode() + ); + } } diff --git a/src/main/java/backendlab/team4you/user/UserEntity.java b/src/main/java/backendlab/team4you/user/UserEntity.java index 44b37eb..825a393 100644 --- a/src/main/java/backendlab/team4you/user/UserEntity.java +++ b/src/main/java/backendlab/team4you/user/UserEntity.java @@ -1,14 +1,6 @@ package backendlab.team4you.user; - import jakarta.persistence.*; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import org.jspecify.annotations.Nullable; - import org.springframework.security.web.webauthn.api.Bytes; import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; @@ -22,17 +14,14 @@ public class UserEntity implements PublicKeyCredentialUserEntity { @Column(name = "id") private String id; - @Column(name = "email", nullable = false, unique = true) private String email; @Column(name = "name", nullable = false, unique = true) private String name; - @Column(name = "display_name") private String displayName; - @Column(name = "first_name") private String firstName; @@ -40,7 +29,6 @@ public class UserEntity implements PublicKeyCredentialUserEntity { @Column(name = "last_name") private String lastName; - @Enumerated(EnumType.STRING) private UserRole role; @@ -62,7 +50,6 @@ public UserEntity(Bytes id, String name, String displayName) { this.displayName = displayName; } - public void setId(Bytes id) { this.id = id != null ? id.toBase64UrlString() : null; } @@ -77,24 +64,20 @@ public void setName(String name) { } @Override - public Bytes getId() { return id != null ? Bytes.fromBase64(id) : null; } @Override public String getDisplayName() { - return (this.firstName != null ? this.firstName : "") + " " + (this.lastName != null ? this.lastName : ""); + return (this.firstName != null ? this.firstName : "") + " " + + (this.lastName != null ? this.lastName : ""); } - - - public void setDisplayName(String displayName) { this.displayName = displayName; } - public LocalDateTime getCreatedAt() { return createdAt; } @@ -143,22 +126,19 @@ public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } - public String getRole() { + public UserRole getRole() { + return role; + } - return role.name(); + public void setRole(UserRole role) { + this.role = role; } public void setRole(String role) { - this.role = UserRole.valueOf(role); } - public String getIdAsString() { + public String getIdAsString() { return this.id; } - - } - - - diff --git a/src/main/java/backendlab/team4you/user/UserRepository.java b/src/main/java/backendlab/team4you/user/UserRepository.java index 1e49b4e..4fabc94 100644 --- a/src/main/java/backendlab/team4you/user/UserRepository.java +++ b/src/main/java/backendlab/team4you/user/UserRepository.java @@ -23,7 +23,7 @@ public interface UserRepository extends JpaRepository { Page findAll(Pageable pageable); - Page findByRole(String admin, Pageable pageable); + Page findByRole(UserRole role, Pageable pageable); UserEntity findByDisplayName(String DisplayName); } diff --git a/src/main/java/backendlab/team4you/user/UserRole.java b/src/main/java/backendlab/team4you/user/UserRole.java index 47c0c62..5064c42 100644 --- a/src/main/java/backendlab/team4you/user/UserRole.java +++ b/src/main/java/backendlab/team4you/user/UserRole.java @@ -2,7 +2,7 @@ public enum UserRole { - ROLE_USER, - ROLE_ADMIN, + USER, + ADMIN, } diff --git a/src/main/java/backendlab/team4you/user/UserService.java b/src/main/java/backendlab/team4you/user/UserService.java index b77f7c7..5025e2a 100644 --- a/src/main/java/backendlab/team4you/user/UserService.java +++ b/src/main/java/backendlab/team4you/user/UserService.java @@ -5,8 +5,6 @@ import backendlab.team4you.exceptions.UserNotFoundException; import jakarta.transaction.Transactional; -import org.jspecify.annotations.Nullable; - import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -16,14 +14,10 @@ import org.springframework.security.web.webauthn.api.Bytes; import org.springframework.stereotype.Service; -import org.springframework.web.bind.annotation.DeleteMapping; - - import java.security.Principal; import org.springframework.web.server.ResponseStatusException; - import java.security.SecureRandom; import java.util.List; @@ -32,8 +26,6 @@ @Service public class UserService { - - UserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; private final SecureRandom random = new SecureRandom(); @@ -46,7 +38,6 @@ public UserService(UserRepository userRepository, BCryptPasswordEncoder password this.passwordEncoder = passwordEncoder; } - @Transactional public void save(UserEntity userEntity){ userRepository.save(userEntity); @@ -74,7 +65,6 @@ public UserEntity update(UserEntity userEntity){ return userRepository.save(userEntity); } - public void registerUser(UserRegistrationDTO dto) { if (dto.name() == null || dto.name().isBlank()) { @@ -97,10 +87,11 @@ public void registerUser(UserRegistrationDTO dto) { user.setEmail(cleanEmail); user.setPhoneNumber(dto.phoneNumber()); - String hashedPw = passwordEncoder.encode(dto.password()); user.setPasswordHash(hashedPw); + user.setRole(UserRole.USER); + userRepository.save(user); } @@ -135,8 +126,7 @@ public UserEntity registerWebAuthnUser(String username, String displayName, Stri userEntity.setLastName(lastName); //Every user that register themselves will automatically get the role USER assigned - String assignedRole = "ROLE_USER"; - userEntity.setRole(assignedRole); + userEntity.setRole(UserRole.USER); try { return userRepository.save(userEntity); @@ -185,9 +175,19 @@ public Page getUsers(int page, int size) { return userRepository.findAll(PageRequest.of(page, size)); } public Page getAdmins(int page, int size) { - return userRepository.findByRole("ROLE_ADMIN", PageRequest.of(page, size)); + return userRepository.findByRole(UserRole.ADMIN, PageRequest.of(page, size)); } public UserEntity findByUsername(String disPlayName) { return userRepository.findByDisplayName(disPlayName); } + + @Transactional + public UserEntity getCurrentUser(Principal principal) { + if (principal == null || principal.getName() == null || principal.getName().isBlank()) { + throw new UserNotFoundException("No authenticated user found"); + } + + return userRepository.findByName(principal.getName().trim()) + .orElseThrow(() -> new UserNotFoundException("User not found: " + principal.getName())); + } } diff --git a/src/main/resources/db/migration/V15__case_file_numbering.sql b/src/main/resources/db/migration/V15__case_file_numbering.sql new file mode 100644 index 0000000..7ea6072 --- /dev/null +++ b/src/main/resources/db/migration/V15__case_file_numbering.sql @@ -0,0 +1,38 @@ +ALTER TABLE case_file + ADD document_number INTEGER; + +ALTER TABLE case_file + ADD document_reference VARCHAR(255); + +WITH numbered_files AS ( + SELECT + cf.id, + cf.case_record_id, + cr.case_number, + ROW_NUMBER() OVER ( + PARTITION BY cf.case_record_id + ORDER BY cf.uploaded_at ASC, cf.id ASC + ) AS new_document_number + FROM case_file cf + JOIN case_record cr ON cr.id = cf.case_record_id +) +UPDATE case_file cf +SET + document_number = nf.new_document_number, + document_reference = nf.case_number || '-' || nf.new_document_number +FROM numbered_files nf +WHERE cf.id = nf.id; + +ALTER TABLE case_file + ALTER COLUMN document_number SET NOT NULL; + +ALTER TABLE case_file + ALTER COLUMN document_reference SET NOT NULL; + +ALTER TABLE case_file + ADD CONSTRAINT uk_case_file_case_record_document_number + UNIQUE (case_record_id, document_number); + +ALTER TABLE case_file + ADD CONSTRAINT uk_case_file_document_reference + UNIQUE (document_reference); \ No newline at end of file diff --git a/src/main/resources/db/migration/V16__case_file_confidentiality.sql b/src/main/resources/db/migration/V16__case_file_confidentiality.sql new file mode 100644 index 0000000..53bde5e --- /dev/null +++ b/src/main/resources/db/migration/V16__case_file_confidentiality.sql @@ -0,0 +1,9 @@ +ALTER TABLE case_file + ADD confidentiality_level VARCHAR(50); + +UPDATE case_file +SET confidentiality_level = 'OPEN' +WHERE confidentiality_level IS NULL; + +ALTER TABLE case_file + ALTER COLUMN confidentiality_level SET NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V17__case_file_access.sql b/src/main/resources/db/migration/V17__case_file_access.sql new file mode 100644 index 0000000..8ea8b24 --- /dev/null +++ b/src/main/resources/db/migration/V17__case_file_access.sql @@ -0,0 +1,7 @@ +CREATE TABLE case_file_access ( + id BIGSERIAL PRIMARY KEY, + case_record_id BIGINT NOT NULL REFERENCES case_record(id), + user_id VARCHAR(255) NOT NULL REFERENCES user_entities(id), + can_view_confidential_files BOOLEAN NOT NULL, + CONSTRAINT uk_case_file_access_case_user UNIQUE (case_record_id, user_id) +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V18__case_file_changes.sql b/src/main/resources/db/migration/V18__case_file_changes.sql new file mode 100644 index 0000000..eee2293 --- /dev/null +++ b/src/main/resources/db/migration/V18__case_file_changes.sql @@ -0,0 +1,2 @@ +ALTER TABLE case_record + ALTER COLUMN assigned_user_id DROP NOT NULL; \ No newline at end of file diff --git a/src/main/resources/static/css/case-management.css b/src/main/resources/static/css/case-management.css new file mode 100644 index 0000000..0cfb1c9 --- /dev/null +++ b/src/main/resources/static/css/case-management.css @@ -0,0 +1,346 @@ +:root { + --color-bg: #161a2d; + --color-surface: #1f2544; + --color-surface-soft: #2a315c; + --color-primary: #6017a8; + --color-primary-hover: #7433bd; + --color-accent: #00d4ff; + --color-text: #ffffff; + --color-muted: #a0a0b0; + --color-success: #7cfc98; + --color-error: #ff9b9b; + --color-border: rgba(255, 255, 255, 0.14); + --color-input-bg: rgba(255, 255, 255, 0.08); + --color-row-bg: rgba(255, 255, 255, 0.05); + --shadow-panel: 0 10px 25px rgba(0, 0, 0, 0.22); +} + +#content-area { + background: var(--color-bg); + min-height: 100vh; +} + +.case-management-wrapper { + display: grid; + grid-template-columns: 320px 1fr 1fr; + gap: 20px; + padding: 20px; + align-items: start; +} + +.case-panel { + background: var(--color-surface); + color: var(--color-text); + border-radius: 20px; + padding: 24px; + min-height: 70vh; + box-shadow: var(--shadow-panel); +} + +.case-panel h2 { + margin: 0 0 16px; + font-size: 2rem; + line-height: 1.2; + color: var(--color-text); +} + +.case-panel h3 { + margin: 0 0 12px; + font-size: 1.15rem; + color: var(--color-text); +} + +.case-panel p, +.case-panel label, +.case-panel strong { + color: var(--color-text); +} + +.case-panel span { + color: inherit; +} + +.registry-panel-content, +.case-record-panel-content, +.case-detail-panel-content { + color: var(--color-text); +} + +.feedback-message { + margin: 0 0 12px; + font-size: 0.95rem; +} + +.success-message { + color: var(--color-success); +} + +.error-message { + color: var(--color-error); +} + +.section-divider { + margin: 20px 0; + border: none; + border-top: 1px solid var(--color-border); +} + +.form-section-title { + margin: 20px 0 12px; + color: var(--color-text); + font-size: 1.15rem; + font-weight: 700; +} + +.case-record-heading { + margin-bottom: 18px; + color: var(--color-text); +} + +.case-record-heading-name { + color: inherit; + font-weight: 700; +} + +.case-panel .form-group { + margin-bottom: 14px; +} + +.case-panel .form-group label { + display: block; + margin-bottom: 6px; + font-size: 0.95rem; + color: var(--color-text); +} + +.case-panel .form-group input, +.case-panel .form-group textarea, +.case-panel .form-group select { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--color-border); + background: var(--color-input-bg); + color: var(--color-text); + outline: none; + box-sizing: border-box; + font-size: 1rem; +} + +.case-panel .form-group input::placeholder, +.case-panel .form-group textarea::placeholder { + color: var(--color-muted); +} + +.case-panel .form-group textarea { + min-height: 100px; + resize: vertical; +} + +.case-panel .form-group input:focus, +.case-panel .form-group textarea:focus, +.case-panel .form-group select:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.16); +} + +.case-panel .form-group select option { + color: #111111; +} + +.btn-primary { + display: inline-block; + width: 100%; + padding: 12px 16px; + border: none; + border-radius: 12px; + background: var(--color-primary); + color: var(--color-text); + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease, opacity 0.2s ease; + box-sizing: border-box; + appearance: none; + -webkit-appearance: none; +} + +.btn-primary:hover { + background: var(--color-primary-hover); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); +} + +.registry-list, +.case-record-list, +.case-file-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; + padding: 0; + margin: 16px 0 0 0; +} + +.registry-list li, +.case-record-list li, +.case-file-list li { + list-style: none; + margin: 0; + padding: 0; + background: transparent; +} + +.registry-button, +.case-record-button { + display: block; + width: 100%; + padding: 14px 16px; + border: 1px solid var(--color-border); + border-radius: 14px; + background: linear-gradient(180deg, var(--color-surface-soft), var(--color-primary)); + color: var(--color-text); + cursor: pointer; + text-align: left; + font-size: 1rem; + font-weight: 600; + box-sizing: border-box; + transition: background 0.2s ease, transform 0.2s ease, border-color 0.2s ease; + appearance: none; + -webkit-appearance: none; +} + +.registry-button:hover, +.case-record-button:hover { + background: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary)); + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.22); +} + +.registry-button:active, +.case-record-button:active { + transform: translateY(0); +} + +.registry-name, +.case-record-number { + color: inherit; + font-weight: 700; +} + +.registry-code, +.case-record-title { + margin-left: 8px; + color: rgba(255, 255, 255, 0.92); +} + +.empty-state { + color: var(--color-muted); + font-style: italic; + padding: 4px 0; +} + +.file-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + background: var(--color-row-bg); + border: 1px solid var(--color-border); +} + +.file-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.file-actions a, +.file-actions button { + border: none; + background: transparent; + color: var(--color-accent); + cursor: pointer; + font-size: 0.95rem; + text-decoration: none; +} + +.file-actions a:hover, +.file-actions button:hover { + opacity: 0.85; +} + +@media (max-width: 1100px) { + .case-management-wrapper { + grid-template-columns: 1fr; + } + + .case-panel { + min-height: auto; + } +} + +/* större luft efter huvudrubrik */ +.case-panel h2 { + margin-bottom: 20px; +} + +/* spacing runt formulärsektion */ +.form-section-title { + margin-top: 24px; + margin-bottom: 12px; +} + +/* extra luft före listor */ +.case-record-list, +.registry-list { + margin-top: 12px; +} + +/* lite luft efter formulär */ +form { + margin-bottom: 16px; +} + +/* snyggare separator */ +.section-divider { + margin: 24px 0; +} + +.registry-panel-content, +.case-record-panel-content, +.case-detail-panel-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-help { + display: block; + margin-top: 6px; + font-size: 0.85rem; + color: var(--color-muted); +} + +.feedback-message { + padding: 10px 12px; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 600; + margin: 8px 0 12px; +} + +.success-message { + color: #0f5132; + background: #d1e7dd; +} + +.error-message { + color: #842029; + background: #f8d7da; +} \ No newline at end of file diff --git a/src/main/resources/static/css/dashboard.css b/src/main/resources/static/css/dashboard.css index 5a8e8ce..69c1827 100644 --- a/src/main/resources/static/css/dashboard.css +++ b/src/main/resources/static/css/dashboard.css @@ -15,8 +15,9 @@ font-family: Poppins, sans-serif; } -li, ul, span -{ +.sidebar li, +.sidebar a, +.sidebar span { list-style-type: none; text-decoration: none; padding:10px; diff --git a/src/main/resources/templates/dashboard-layout.html b/src/main/resources/templates/dashboard-layout.html index 7daf265..95fa544 100644 --- a/src/main/resources/templates/dashboard-layout.html +++ b/src/main/resources/templates/dashboard-layout.html @@ -55,6 +55,16 @@ Min Profil +
  • + + + Ärenden + +
  • diff --git a/src/main/resources/templates/dashboard/case-management.html b/src/main/resources/templates/dashboard/case-management.html new file mode 100644 index 0000000..98a8ac5 --- /dev/null +++ b/src/main/resources/templates/dashboard/case-management.html @@ -0,0 +1,9 @@ + + + +
    +
    +
    + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/admin-sidenav.html b/src/main/resources/templates/fragments/admin-sidenav.html index 682eb40..002e0da 100644 --- a/src/main/resources/templates/fragments/admin-sidenav.html +++ b/src/main/resources/templates/fragments/admin-sidenav.html @@ -8,6 +8,13 @@