diff --git a/pom.xml b/pom.xml index 884bf38c..075bd8bd 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,11 @@ spring-boot-configuration-processor true + + org.apache.pdfbox + pdfbox + 3.0.7 + diff --git a/src/main/java/backendlab/team4you/Team4youApplication.java b/src/main/java/backendlab/team4you/Team4youApplication.java index abd780de..e4717531 100644 --- a/src/main/java/backendlab/team4you/Team4youApplication.java +++ b/src/main/java/backendlab/team4you/Team4youApplication.java @@ -3,7 +3,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.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -17,65 +17,82 @@ public class Team4youApplication { public static void main(String[] args) { SpringApplication.run(Team4youApplication.class, args); } - - @Bean @Profile("dev") - ApplicationRunner init(UserRepository repository, BCryptPasswordEncoder encoder) { + @Bean + CommandLineRunner commandLineRunner(UserRepository repository, BCryptPasswordEncoder encoder) { return args -> { - if (repository.count() == 0) { - - UserEntity devAdmin = new UserEntity( - Bytes.fromBase64("YWRtaW4="), - "dev", - "admin" - ); - - devAdmin.setPasswordHash(encoder.encode("123456")); - devAdmin.setRole(UserRole.ADMIN); - devAdmin.setEmail("devadmin@gmail.com"); - - repository.save(devAdmin); - System.out.println("✅ Admin created"); - - UserEntity devUser = new UserEntity( - Bytes.fromBase64("dXNlcg=="), - "user", - "user" - ); - - devUser.setPasswordHash(encoder.encode("1234")); - devUser.setRole(UserRole.USER); - devUser.setEmail("devuser@gmail.com"); - - repository.save(devUser); - System.out.println("✅ User created"); - - UserEntity officer1 = new UserEntity( - Bytes.fromBase64("YLRtaW5="), - "officer1", - "caseofficer1" - ); - - officer1.setPasswordHash(encoder.encode("officer1")); - officer1.setRole(UserRole.CASE_OFFICER); - officer1.setEmail("devcaseofficer@gmail.com"); - - repository.save(officer1); - System.out.println("✅ Case officer 1 created"); - - UserEntity officer2 = new UserEntity( - Bytes.fromBase64("YLRtaG6="), - "officer2", - "caseofficer2" - ); - - officer2.setPasswordHash(encoder.encode("officer2")); - officer2.setRole(UserRole.CASE_OFFICER); - officer2.setEmail("devcaseofficer2@gmail.com"); - - repository.save(officer2); - System.out.println("✅ Case officer 2 created"); - } + seedUser( + repository, + encoder, + "dev", + "123456", + "admin", + "devadmin@gmail.com", + UserRole.ADMIN, + "YWRtaW4=" + ); + + seedUser( + repository, + encoder, + "user", + "1234", + "user", + "devuser@gmail.com", + UserRole.USER, + "dXNlcg==" + ); + + seedUser( + repository, + encoder, + "officer1", + "officer1", + "caseofficer1", + "devcaseofficer@gmail.com", + UserRole.CASE_OFFICER, + "YLRtaW5=" + ); + + seedUser( + repository, + encoder, + "officer2", + "officer2", + "caseofficer2", + "devcaseofficer2@gmail.com", + UserRole.CASE_OFFICER, + "YLRtaG6=" + ); }; } + + private void seedUser( + UserRepository repository, + BCryptPasswordEncoder encoder, + String username, + String password, + String displayName, + String email, + UserRole role, + String base64Id + ) { + if (repository.existsByName(username)) { + return; + } + + UserEntity user = new UserEntity( + Bytes.fromBase64(base64Id), + username, + displayName + ); + + user.setPasswordHash(encoder.encode(password)); + user.setRole(role); + user.setEmail(email); + + repository.save(user); + + System.out.println("✅ User created: " + username + " (" + role + ")"); + } } diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileService.java b/src/main/java/backendlab/team4you/casefile/CaseFileService.java index 88fa8679..aa7e41b7 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileService.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileService.java @@ -194,6 +194,74 @@ public void deleteFile(Long caseRecordId, Long fileId, UserEntity actor) { } } + @Transactional + public CaseFile uploadGeneratedFile( + Long caseRecordId, + String originalFilename, + String contentType, + byte[] bytes, + ConfidentialityLevel confidentialityLevel, + UserEntity actor + ) { + ConfidentialityLevel effectiveConfidentialityLevel = + confidentialityLevel != null ? confidentialityLevel : ConfidentialityLevel.OPEN; + + CaseRecord caseRecord = caseRecordRepository.findByIdWithLock(caseRecordId) + .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); + + if (!caseFileAccessService.canUploadFile(actor, caseRecord, effectiveConfidentialityLevel)) { + throw new AccessDeniedException("Du har inte behörighet att ladda upp denna fil."); + } + + if (bytes == null) { + throw new IllegalArgumentException("Filinnehåll saknas."); + } + if (bytes.length > MAX_FILE_SIZE_BYTES) { + throw new FileTooLargeException(MAX_FILE_SIZE_BYTES); + } + + if (originalFilename == null || originalFilename.isBlank()) { + throw new InvalidFileNameException("Filnamn måste anges."); + } + + String normalizedContentType = normalizeContentType(contentType); + + int nextDocumentNumber = allocateNextDocumentNumber(caseRecord.getId()); + String documentReference = caseRecord.getCaseNumber() + "-" + nextDocumentNumber; + String s3Key = buildUniqueS3Key(caseRecord, originalFilename); + + boolean uploadedToS3 = false; + + try { + s3Service.uploadFileIfAbsent(s3Key, bytes, normalizedContentType); + uploadedToS3 = true; + + CaseFile caseFile = new CaseFile(); + caseFile.setCaseRecord(caseRecord); + caseFile.setOriginalFilename(originalFilename); + caseFile.setS3Key(s3Key); + caseFile.setContentType(normalizedContentType); + caseFile.setSize(bytes.length); + 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 exception) { + if (uploadedToS3) { + cleanupUploadedObjectIfPossible(s3Key, exception); + } + throw exception; + } + } + private String normalizeContentType(String contentType) { if (contentType == null || contentType.isBlank()) { return MediaType.APPLICATION_OCTET_STREAM_VALUE; diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java b/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java index d0240f1a..be49d74d 100644 --- a/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java +++ b/src/main/java/backendlab/team4you/caserecord/CaseRecordRepository.java @@ -1,5 +1,6 @@ package backendlab.team4you.caserecord; +import backendlab.team4you.registry.Registry; import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -20,6 +21,12 @@ public interface CaseRecordRepository extends JpaRepository { Optional findByIdWithLock(Long id); boolean existsByCaseNumber(String caseNumber); - @Query("SELECT c FROM CaseRecord c WHERE c.assignedUser.id = :officerId") + @Query(""" + SELECT c FROM CaseRecord c + WHERE c.assignedUser.id = :officerId + ORDER BY c.createdAt DESC, c.id DESC + """) Page findByAssignedUserId(String officerId, Pageable pageable); + + Optional findByRegistryAndTitle(Registry registry, String title); } diff --git a/src/main/java/backendlab/team4you/caserecord/CaseRecordService.java b/src/main/java/backendlab/team4you/caserecord/CaseRecordService.java index bf65ed33..df228e1f 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.common.ConfidentialityLevel; import backendlab.team4you.exceptions.CaseRecordNotFoundException; import backendlab.team4you.exceptions.RegistryNotFoundException; import backendlab.team4you.exceptions.UserNotFoundException; @@ -67,28 +68,7 @@ public CaseRecordResponseDto createCaseRecord(CaseRecordRequestDto requestDto) { } private String allocateNextCaseNumber(Registry registry) { - int year = LocalDateTime.now().getYear(); - int maxAttempts = 3; - - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return tryAllocateNextCaseNumber(registry, year); - } catch (DataIntegrityViolationException exception) { - if (attempt == maxAttempts) { - throw new IllegalStateException( - "failed to allocate case number after " + maxAttempts + - " attempts for registry " + registry.getId() + - " and year " + year, - exception - ); - } - } - } - - throw new IllegalStateException( - "unreachable state while allocating case number for registry " + - registry.getId() + " and year " + year - ); + return allocateNextCaseNumber(registry, LocalDateTime.now().getYear()); } private String tryAllocateNextCaseNumber(Registry registry, int year) { @@ -164,6 +144,19 @@ public CaseRecordResponseDto updateCaseRecord(Long caseRecordId, CaseStatus stat return toResponseDto(savedCaseRecord); } + @Transactional + public CaseRecord findOrCreateAnnualProtocolCase( + Registry registry, + int year, + UserEntity currentUser + ) { + String title = "Protokoll för " + registry.getCode() + " år " + year; + + return caseRecordRepository + .findByRegistryAndTitle(registry, title) + .orElseGet(() -> createAnnualProtocolCase(registry, year, title, currentUser)); + } + private String normalizeStatus(String status) { if (status == null || status.isBlank()) { throw new IllegalArgumentException("status is required"); @@ -178,4 +171,51 @@ private String normalizeStatus(String status) { return normalizedStatus; } + + private CaseRecord createAnnualProtocolCase( + Registry registry, + int year, + String title, + UserEntity currentUser + ) { + CaseRecord caseRecord = new CaseRecord( + registry, + title, + "Årsärende för protokoll inom " + registry.getCode() + " år " + year, + CaseStatus.OPEN, + currentUser, + null, + ConfidentialityLevel.OPEN, + LocalDateTime.of(year, 1, 1, 0, 0) + ); + + String caseNumber = allocateNextCaseNumber(registry, year); + caseRecord.setCaseNumber(caseNumber); + + return caseRecordRepository.save(caseRecord); + } + + private String allocateNextCaseNumber(Registry registry, int year) { + int maxAttempts = 3; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return tryAllocateNextCaseNumber(registry, year); + } catch (DataIntegrityViolationException exception) { + if (attempt == maxAttempts) { + throw new IllegalStateException( + "failed to allocate case number after " + maxAttempts + + " attempts for registry " + registry.getId() + + " and year " + year, + exception + ); + } + } + } + + throw new IllegalStateException( + "unreachable state while allocating case number for registry " + + registry.getId() + " and year " + year + ); + } } diff --git a/src/main/java/backendlab/team4you/config/SecurityConfig.java b/src/main/java/backendlab/team4you/config/SecurityConfig.java index dcf9c160..46da2f58 100644 --- a/src/main/java/backendlab/team4you/config/SecurityConfig.java +++ b/src/main/java/backendlab/team4you/config/SecurityConfig.java @@ -45,6 +45,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, .requestMatchers("/webauthn/**").hasAnyRole("USER", ADMIN, CASE_OFFICER) .requestMatchers("/admin/**").hasRole(ADMIN) + .requestMatchers("/case-officer").hasRole(CASE_OFFICER) .requestMatchers("/case-officer/**").hasRole(CASE_OFFICER) .requestMatchers("/home", "/profile/**").hasAnyRole("USER", ADMIN, CASE_OFFICER) .requestMatchers("/add-passkey", "/webauthn/register/**").hasAnyRole("USER", ADMIN, CASE_OFFICER) diff --git a/src/main/java/backendlab/team4you/controller/CaseOfficerController.java b/src/main/java/backendlab/team4you/controller/CaseOfficerController.java index f63d86db..a4ad4b1c 100644 --- a/src/main/java/backendlab/team4you/controller/CaseOfficerController.java +++ b/src/main/java/backendlab/team4you/controller/CaseOfficerController.java @@ -2,7 +2,6 @@ import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; -import backendlab.team4you.caserecord.CaseRecordService; import backendlab.team4you.caserecord.CaseStatus; import backendlab.team4you.exceptions.CaseRecordNotFoundException; import backendlab.team4you.exceptions.UserNotFoundException; @@ -59,14 +58,15 @@ public String listCases( @PostMapping("/case-officer/cases/close") @ResponseBody - public String closeCase(@RequestParam String id, Authentication auth) { + public String closeCase(@RequestParam Long id, Authentication auth) { UserEntity officer = userRepository.findByName(auth.getName()) .orElseThrow(() -> new UserNotFoundException("Case Officer not found")); - CaseRecord caseRecord = caseRecordRepository.findById(Long.valueOf(id)) + CaseRecord caseRecord = caseRecordRepository.findById(id) .orElseThrow(() -> new CaseRecordNotFoundException(id)); - if (!caseRecord.getAssignedUser().getId().equals(officer.getId())) { + if (caseRecord.getAssignedUser() == null + || !caseRecord.getAssignedUser().getIdAsString().equals(officer.getIdAsString())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not assigned to this officer"); } diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java index 6424eb17..95f225a9 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java @@ -64,7 +64,8 @@ public ResponseEntity handleNotFound(RuntimeException ex) { @ExceptionHandler({ InvalidFileNameException.class, IllegalArgumentException.class, - InvalidMeetingStateException.class + InvalidMeetingStateException.class, + ProtocolNotReadyForPdfException.class }) public ResponseEntity handleBadRequest(RuntimeException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -94,7 +95,9 @@ public ResponseEntity handleFileTooLarge(FileTooLargeException FileKeyConflictException.class, DuplicateMeetingAgendaItemException.class, DuplicateMeetingAgendaDocumentException.class, - ProtocolAlreadyExistsException.class + ProtocolAlreadyExistsException.class, + ProtocolAlreadyArchivedException.class, + MeetingHasProtocolException.class }) public ResponseEntity handleConflict(RuntimeException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java index 8becb103..f03bc1b4 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java @@ -76,4 +76,25 @@ public String handleProtocolAlreadyExists(ProtocolAlreadyExistsException ex, Mod model.addAttribute("errorMessage", "Ett protokoll finns redan för det här sammanträdet."); return "error"; } + + @ExceptionHandler(ProtocolAlreadyArchivedException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public String handleProtocolAlreadyArchived(ProtocolAlreadyArchivedException ex, Model model) { + model.addAttribute("errorMessage", ex.getMessage()); + return "error"; + } + + @ExceptionHandler(ProtocolNotReadyForPdfException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleProtocolNotReady(ProtocolNotReadyForPdfException ex, Model model) { + model.addAttribute("errorMessage", ex.getMessage()); + return "error"; + } + + @ExceptionHandler(MeetingHasProtocolException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public String handleMeetingHasProtocol(MeetingHasProtocolException ex, Model model) { + model.addAttribute("errorMessage", ex.getMessage()); + return "error"; + } } diff --git a/src/main/java/backendlab/team4you/exceptions/MeetingHasProtocolException.java b/src/main/java/backendlab/team4you/exceptions/MeetingHasProtocolException.java new file mode 100644 index 00000000..d4a81fd0 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/MeetingHasProtocolException.java @@ -0,0 +1,14 @@ +package backendlab.team4you.exceptions; + +public class MeetingHasProtocolException extends RuntimeException { + private final Long meetingId; + + public MeetingHasProtocolException(Long meetingId) { + super("Sammanträdet har redan ett protokoll och kan inte längre ändras."); + this.meetingId = meetingId; + } + + public Long getMeetingId() { + return meetingId; + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/ProtocolAlreadyArchivedException.java b/src/main/java/backendlab/team4you/exceptions/ProtocolAlreadyArchivedException.java new file mode 100644 index 00000000..e3b31196 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/ProtocolAlreadyArchivedException.java @@ -0,0 +1,14 @@ +package backendlab.team4you.exceptions; + +public class ProtocolAlreadyArchivedException extends RuntimeException { + private final Long protocolId; + + public ProtocolAlreadyArchivedException(Long protocolId) { + super("Protokollet är redan arkiverat och kan inte längre ändras."); + this.protocolId = protocolId; + } + + public Long getProtocolId() { + return protocolId; + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/ProtocolNotReadyForPdfException.java b/src/main/java/backendlab/team4you/exceptions/ProtocolNotReadyForPdfException.java new file mode 100644 index 00000000..95849593 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/ProtocolNotReadyForPdfException.java @@ -0,0 +1,14 @@ +package backendlab.team4you.exceptions; + +public class ProtocolNotReadyForPdfException extends RuntimeException { + private final Long protocolId; + + public ProtocolNotReadyForPdfException(Long protocolId) { + super("Alla paragrafer måste ha beslut och beslutstext innan PDF kan skapas."); + this.protocolId = protocolId; + } + + public Long getProtocolId() { + return protocolId; + } +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java index 5ede0e83..c790343e 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingRepository.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java @@ -1,10 +1,13 @@ package backendlab.team4you.meeting; import backendlab.team4you.registry.Registry; +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 MeetingRepository extends JpaRepository { @@ -24,4 +27,8 @@ and not exists ( order by m.startsAt desc """) List findCompletedMeetingsWithoutProtocol(); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Meeting m where m.id = :id") + Optional findByIdWithLock(Long id); } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index a018cf2a..9f54c0b6 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -5,6 +5,7 @@ import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; import backendlab.team4you.exceptions.*; +import backendlab.team4you.protocol.ProtocolRepository; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.springframework.stereotype.Service; @@ -23,6 +24,7 @@ public class MeetingService { private final RegistryRepository registryRepository; private final CaseRecordRepository caseRecordRepository; private final CaseFileRepository caseFileRepository; + private final ProtocolRepository protocolRepository; public MeetingService( MeetingRepository meetingRepository, @@ -30,7 +32,8 @@ public MeetingService( MeetingAgendaDocumentRepository meetingAgendaDocumentRepository, RegistryRepository registryRepository, CaseRecordRepository caseRecordRepository, - CaseFileRepository caseFileRepository + CaseFileRepository caseFileRepository, + ProtocolRepository protocolRepository ) { this.meetingRepository = meetingRepository; this.meetingAgendaItemRepository = meetingAgendaItemRepository; @@ -38,6 +41,7 @@ public MeetingService( this.registryRepository = registryRepository; this.caseRecordRepository = caseRecordRepository; this.caseFileRepository = caseFileRepository; + this.protocolRepository = protocolRepository; } public Meeting createMeeting( @@ -90,9 +94,6 @@ public Meeting updateMeeting( String notes, MeetingStatus status ) { - if (meetingId == null) { - throw new InvalidMeetingStateException("Meeting-id måste anges."); - } if (title == null || title.isBlank()) { throw new InvalidMeetingStateException("Titel måste anges."); @@ -110,8 +111,7 @@ public Meeting updateMeeting( throw new InvalidMeetingStateException("Status måste anges."); } - Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingNotFoundException(meetingId)); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); meeting.setTitle(title.trim()); meeting.setStartsAt(startsAt); @@ -125,13 +125,7 @@ public Meeting updateMeeting( @Transactional public void deleteMeeting(Long meetingId) { - if (meetingId == null) { - throw new InvalidMeetingStateException("Meeting-id måste anges."); - } - - Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new MeetingNotFoundException(meetingId)); - + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); meetingRepository.delete(meeting); } @@ -169,7 +163,7 @@ public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordI throw new InvalidMeetingStateException("Case record-id måste anges."); } - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); @@ -194,7 +188,7 @@ public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordI @Transactional public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); @@ -221,7 +215,7 @@ public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { @Transactional public void moveAgendaItemDown(Long meetingId, Long agendaItemId) { - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); @@ -251,7 +245,7 @@ public void moveAgendaItemDown(Long meetingId, Long agendaItemId) { } public void removeAgendaItem(Long meetingId, Long agendaItemId) { - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); @@ -281,7 +275,7 @@ public List getAvailableCaseFilesForAgendaItem(Long agendaItemId) { } public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agendaItemId, Long caseFileId) { - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); @@ -307,7 +301,7 @@ public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agenda } public void removeDocumentFromAgendaItem(Long meetingId, Long agendaItemId, Long documentId) { - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); @@ -331,7 +325,7 @@ public Meeting updateMeetingStatus(Long meetingId, MeetingStatus status) { throw new IllegalArgumentException("Status måste anges."); } - Meeting meeting = getMeetingById(meetingId); + Meeting meeting = getMeetingByIdWithLockAndEnsureNoProtocol(meetingId); meeting.setStatus(status); return meetingRepository.save(meeting); @@ -373,4 +367,18 @@ private String blankToNull(String value) { } return value.trim(); } + + private Meeting getMeetingByIdWithLockAndEnsureNoProtocol(Long meetingId) { + if (meetingId == null) { + throw new InvalidMeetingStateException("Meeting-id måste anges."); + } + + Meeting meeting = meetingRepository.findByIdWithLock(meetingId) + .orElseThrow(() -> new MeetingNotFoundException(meetingId)); + + if (protocolRepository.existsByMeetingId(meetingId)) { + throw new MeetingHasProtocolException(meetingId); + } + return meeting; + } } diff --git a/src/main/java/backendlab/team4you/protocol/DashboardProtocolController.java b/src/main/java/backendlab/team4you/protocol/DashboardProtocolController.java new file mode 100644 index 00000000..ec7ff112 --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/DashboardProtocolController.java @@ -0,0 +1,55 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +@Controller +@RequestMapping("/dashboard/protocols") +public class DashboardProtocolController { + + private final ProtocolRepository protocolRepository; + private final ProtocolService protocolService; + private final ProtocolViewService protocolViewService; + private final UserService userService; + + public DashboardProtocolController( + ProtocolRepository protocolRepository, + ProtocolService protocolService, + ProtocolViewService protocolViewService, + UserService userService + ) { + this.protocolRepository = protocolRepository; + this.protocolService = protocolService; + this.protocolViewService = protocolViewService; + this.userService = userService; + } + + @GetMapping + public String listProtocols(Model model) { + model.addAttribute("protocols", protocolRepository.findAll()); + return "fragments/dashboard-protocols :: content"; + } + + @GetMapping("/{protocolId}") + public String viewProtocol( + @PathVariable Long protocolId, + Principal principal, + Model model + ) { + UserEntity currentUser = userService.getCurrentUser(principal); + + model.addAttribute("protocols", protocolRepository.findAll()); + model.addAttribute("selectedProtocol", protocolService.getProtocol(protocolId)); + model.addAttribute( + "paragraphViews", + protocolViewService.getParagraphsForViewer(protocolId, currentUser) + ); + + return "fragments/dashboard-protocols :: content"; + } +} diff --git a/src/main/java/backendlab/team4you/protocol/Protocol.java b/src/main/java/backendlab/team4you/protocol/Protocol.java index 4fac9481..b4e422e7 100644 --- a/src/main/java/backendlab/team4you/protocol/Protocol.java +++ b/src/main/java/backendlab/team4you/protocol/Protocol.java @@ -1,5 +1,6 @@ package backendlab.team4you.protocol; +import backendlab.team4you.casefile.CaseFile; import backendlab.team4you.meeting.Meeting; import backendlab.team4you.registry.Registry; import jakarta.persistence.*; @@ -43,6 +44,10 @@ public class Protocol { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "archived_pdf_file_id") + private CaseFile archivedPdfFile; + protected Protocol() { } @@ -72,6 +77,11 @@ public void addParagraph(ProtocolParagraph paragraph) { paragraph.setProtocol(this); } + public boolean isReadyForPdf() { + return !paragraphs.isEmpty() + && paragraphs.stream().allMatch(ProtocolParagraph::hasDecision); + } + public Long getId() { return id; } @@ -99,4 +109,12 @@ public List getParagraphs() { public LocalDateTime getCreatedAt() { return createdAt; } + + public CaseFile getArchivedPdfFile() { + return archivedPdfFile; + } + + public void setArchivedPdfFile(CaseFile archivedPdfFile) { + this.archivedPdfFile = archivedPdfFile; + } } diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolArchiveController.java b/src/main/java/backendlab/team4you/protocol/ProtocolArchiveController.java new file mode 100644 index 00000000..d30d8591 --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolArchiveController.java @@ -0,0 +1,65 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.exceptions.UserNotFoundException; +import backendlab.team4you.meeting.MeetingRepository; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRepository; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping("/admin/protocols") +@PreAuthorize("hasRole('ADMIN')") +public class ProtocolArchiveController { + + private final ProtocolArchiveService protocolArchiveService; + private final ProtocolRepository protocolRepository; + private final MeetingRepository meetingRepository; + private final ProtocolService protocolService; + private final UserRepository userRepository; + + public ProtocolArchiveController( + ProtocolArchiveService protocolArchiveService, + ProtocolRepository protocolRepository, + MeetingRepository meetingRepository, + ProtocolService protocolService, + UserRepository userRepository) { + this.protocolArchiveService = protocolArchiveService; + this.protocolRepository = protocolRepository; + this.meetingRepository = meetingRepository; + this.protocolService = protocolService; + this.userRepository = userRepository; + } + + @PostMapping("/{protocolId}/archive-pdf") + public String archiveProtocolPdf( + @PathVariable Long protocolId, + Authentication authentication, + Model model + ) { + UserEntity currentUser = userRepository.findByName(authentication.getName()) + .orElseThrow(() -> new UserNotFoundException("user not found: " + authentication.getName())); + + CaseFile archivedFile = protocolArchiveService.archiveProtocolPdf(protocolId, currentUser); + + Protocol protocol = protocolService.getProtocol(protocolId); + + model.addAttribute( + "successMessage", + "Protokollet sparades som PDF i årsärendet som " + archivedFile.getDocumentReference() + "." + ); + model.addAttribute("selectedProtocol", protocol); + model.addAttribute( + "completedMeetingsWithoutProtocol", + meetingRepository.findCompletedMeetingsWithoutProtocol() + ); + model.addAttribute("protocols", protocolRepository.findAll()); + + return "fragments/admin-protocols :: content"; + } +} diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolArchiveService.java b/src/main/java/backendlab/team4you/protocol/ProtocolArchiveService.java new file mode 100644 index 00000000..c6ec3c9a --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolArchiveService.java @@ -0,0 +1,95 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.casefile.CaseFileService; +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordService; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.exceptions.ProtocolNotFoundException; +import backendlab.team4you.exceptions.ProtocolNotReadyForPdfException; +import backendlab.team4you.meeting.Meeting; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.user.UserEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import backendlab.team4you.user.UserRole; + +@Service +public class ProtocolArchiveService { + + private final ProtocolRepository protocolRepository; + private final ProtocolPdfService protocolPdfService; + private final CaseRecordService caseRecordService; + private final CaseFileService caseFileService; + + public ProtocolArchiveService( + ProtocolRepository protocolRepository, + ProtocolPdfService protocolPdfService, + CaseRecordService caseRecordService, + CaseFileService caseFileService + ) { + this.protocolRepository = protocolRepository; + this.protocolPdfService = protocolPdfService; + this.caseRecordService = caseRecordService; + this.caseFileService = caseFileService; + } + + @Transactional + public CaseFile archiveProtocolPdf(Long protocolId, UserEntity currentUser) { + Protocol protocol = protocolRepository.findWithLockById(protocolId) + .orElseThrow(() -> new ProtocolNotFoundException(protocolId)); + + if (protocol.getArchivedPdfFile() != null) { + return protocol.getArchivedPdfFile(); + } + + if (!protocol.isReadyForPdf()) { + throw new ProtocolNotReadyForPdfException(protocol.getId()); + } + + Meeting meeting = protocol.getMeeting(); + Registry registry = meeting.getRegistry(); + int year = meeting.getStartsAt().getYear(); + + CaseRecord annualCase = caseRecordService.findOrCreateAnnualProtocolCase( + registry, + year, + currentUser + ); + + byte[] pdfBytes = protocolPdfService.generatePdf(protocolId, createSystemAdminUser()); + ConfidentialityLevel archiveLevel = protocol.getParagraphs().stream() + .anyMatch(p -> p.getCaseRecord().getConfidentialityLevel() == ConfidentialityLevel.CONFIDENTIAL) + ? ConfidentialityLevel.CONFIDENTIAL + : ConfidentialityLevel.OPEN; + + String filename = "protokoll-" + + registry.getCode().toLowerCase() + + "-" + + year + + "-" + + protocol.getId() + + ".pdf"; + + CaseFile archivedFile = caseFileService.uploadGeneratedFile( + annualCase.getId(), + filename, + "application/pdf", + pdfBytes, + archiveLevel, + currentUser + ); + + protocol.setArchivedPdfFile(archivedFile); + protocolRepository.save(protocol); + + return archivedFile; + } + + private UserEntity createSystemAdminUser() { + UserEntity systemUser = new UserEntity(); + systemUser.setName("system"); + systemUser.setRole(UserRole.ADMIN); + return systemUser; + } +} diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolController.java b/src/main/java/backendlab/team4you/protocol/ProtocolController.java index 15803365..5c9b8650 100644 --- a/src/main/java/backendlab/team4you/protocol/ProtocolController.java +++ b/src/main/java/backendlab/team4you/protocol/ProtocolController.java @@ -1,12 +1,16 @@ package backendlab.team4you.protocol; import backendlab.team4you.meeting.MeetingRepository; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import java.security.Principal; + @Controller @RequestMapping("/admin/protocols") @PreAuthorize("hasRole('ADMIN')") @@ -15,15 +19,21 @@ public class ProtocolController { private final ProtocolService protocolService; private final ProtocolRepository protocolRepository; private final MeetingRepository meetingRepository; + private final ProtocolViewService protocolViewService; + private final UserService userService; public ProtocolController( ProtocolService protocolService, ProtocolRepository protocolRepository, - MeetingRepository meetingRepository + MeetingRepository meetingRepository, + ProtocolViewService protocolViewService, + UserService userService ) { this.protocolService = protocolService; this.protocolRepository = protocolRepository; this.meetingRepository = meetingRepository; + this.protocolViewService = protocolViewService; + this.userService = userService; } @GetMapping @@ -40,13 +50,19 @@ public String listProtocols(Model model) { @PostMapping("/meetings/{meetingId}") public String createProtocol( @PathVariable Long meetingId, + Principal principal, RedirectAttributes redirectAttributes, Model model ) { + UserEntity currentUser = userService.getCurrentUser(principal); Protocol protocol = protocolService.createProtocolForCompletedMeeting(meetingId); model.addAttribute("successMessage", "Protokoll skapades."); model.addAttribute("selectedProtocol", protocol); + model.addAttribute( + "paragraphViews", + protocolViewService.getParagraphsForViewer(protocol.getId(), currentUser) + ); model.addAttribute( "completedMeetingsWithoutProtocol", meetingRepository.findCompletedMeetingsWithoutProtocol() @@ -59,11 +75,14 @@ public String createProtocol( @GetMapping("/{protocolId}") public String viewProtocol( @PathVariable Long protocolId, + Principal principal, Model model ) { + UserEntity currentUser = userService.getCurrentUser(principal); Protocol protocol = protocolService.getProtocol(protocolId); model.addAttribute("selectedProtocol", protocol); + model.addAttribute("paragraphViews", protocolViewService.getParagraphsForViewer(protocolId, currentUser)); model.addAttribute( "completedMeetingsWithoutProtocol", meetingRepository.findCompletedMeetingsWithoutProtocol() @@ -78,8 +97,11 @@ public String updateParagraphDecision( @PathVariable Long paragraphId, @RequestParam ProtocolDecisionType decisionType, @RequestParam String decisionText, + Principal principal, Model model ) { + UserEntity currentUser = userService.getCurrentUser(principal); + Protocol protocol = protocolService.updateParagraphDecision( paragraphId, decisionType, @@ -88,6 +110,10 @@ public String updateParagraphDecision( model.addAttribute("successMessage", "Beslut sparades."); model.addAttribute("selectedProtocol", protocol); + model.addAttribute( + "paragraphViews", + protocolViewService.getParagraphsForViewer(protocol.getId(), currentUser) + ); model.addAttribute( "completedMeetingsWithoutProtocol", meetingRepository.findCompletedMeetingsWithoutProtocol() diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolParagraph.java b/src/main/java/backendlab/team4you/protocol/ProtocolParagraph.java index 1d50040a..24813a4b 100644 --- a/src/main/java/backendlab/team4you/protocol/ProtocolParagraph.java +++ b/src/main/java/backendlab/team4you/protocol/ProtocolParagraph.java @@ -69,6 +69,12 @@ public void updateDecision(ProtocolDecisionType decisionType, String decisionTex this.decisionText = decisionText.trim(); } + public boolean hasDecision() { + return decisionType != null + && decisionText != null + && !decisionText.isBlank(); + } + void setProtocol(Protocol protocol) { this.protocol = Objects.requireNonNull(protocol, "protocol is required"); } diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolParagraphViewDto.java b/src/main/java/backendlab/team4you/protocol/ProtocolParagraphViewDto.java new file mode 100644 index 00000000..2ddc49ff --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolParagraphViewDto.java @@ -0,0 +1,12 @@ +package backendlab.team4you.protocol; + +public record ProtocolParagraphViewDto( + Long id, + String heading, + String caseNumber, + boolean decisionRestricted, + ProtocolDecisionType decisionType, + String decisionLabel, + String decisionText +) { +} diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolPdfController.java b/src/main/java/backendlab/team4you/protocol/ProtocolPdfController.java new file mode 100644 index 00000000..3eeae46e --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolPdfController.java @@ -0,0 +1,34 @@ +package backendlab.team4you.protocol; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin/protocols") +@PreAuthorize("hasRole('ADMIN')") +public class ProtocolPdfController { + + private final ProtocolPdfService protocolPdfService; + + public ProtocolPdfController( + ProtocolPdfService protocolPdfService) { + this.protocolPdfService = protocolPdfService; + } + + @GetMapping("/{protocolId}/pdf") + public ResponseEntity downloadPdf(@PathVariable Long protocolId) { + byte[] pdf = protocolPdfService.generateFullPdf(protocolId); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=protocol-" + protocolId + ".pdf") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } +} diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolPdfService.java b/src/main/java/backendlab/team4you/protocol/ProtocolPdfService.java new file mode 100644 index 00000000..65935526 --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolPdfService.java @@ -0,0 +1,239 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.exceptions.ProtocolNotFoundException; +import backendlab.team4you.exceptions.ProtocolNotReadyForPdfException; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import java.io.InputStream; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import backendlab.team4you.meeting.Meeting; +import backendlab.team4you.registry.Registry; + +@Service +@Transactional(readOnly = true) +public class ProtocolPdfService { + + private final ProtocolRepository protocolRepository; + private final ProtocolViewService protocolViewService; + + private static final float MARGIN_LEFT = 50; + private static final float START_Y = 750; + private static final float BOTTOM_MARGIN = 60; + + public ProtocolPdfService( + ProtocolRepository protocolRepository, + ProtocolViewService protocolViewService + ) { + this.protocolRepository = protocolRepository; + this.protocolViewService = protocolViewService; + } + + public byte[] generatePdf(Long protocolId, UserEntity viewer) { + Protocol protocol = protocolRepository.findById(protocolId) + .orElseThrow(() -> new ProtocolNotFoundException(protocolId)); + + if (!protocol.isReadyForPdf()) { + throw new ProtocolNotReadyForPdfException(protocolId); + } + + List paragraphViews = + protocolViewService.getParagraphsForViewer(protocolId, viewer); + + Meeting meeting = protocol.getMeeting(); + Registry registry = meeting.getRegistry(); + + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + + try (PdfWriter writer = new PdfWriter(document)) { + + writer.writeLine(protocol.getTitle(), true, 16); + writer.addSpacing(12); + + writer.writeLine("Diarium: " + registry.getName(), false, 12); + writer.writeLine("Sammanträde: " + meeting.getTitle(), false, 12); + writer.writeLine("Datum: " + meeting.getStartsAt().toLocalDate(), false, 12); + writer.writeLine("Plats: " + (meeting.getLocation() != null ? meeting.getLocation() : "Ej angiven"), + false, 12); + + writer.addSpacing(16); + + writer.writeLine("Paragrafer", true, 14); + writer.addSpacing(8); + + for (ProtocolParagraphViewDto paragraph : paragraphViews) { + writer.writeLine(paragraph.heading(), true, 12); + + writer.writeLine("Ärendenummer: " + paragraph.caseNumber(), + false, 12); + + if (paragraph.decisionRestricted()) { + writer.writeLine("Beslut: Beslutet omfattas av sekretess.", + false, 12); + } else { + if (paragraph.decisionLabel() != null) { + writer.writeLine("Beslut: " + paragraph.decisionLabel(), + false, 12); + } + + if (paragraph.decisionText() != null && !paragraph.decisionText().isBlank()) { + writer.writeLine("Beslutstext:", false, 12); + + for (String line : splitText(paragraph.decisionText(), 85)) { + writer.writeLine(line, false, 12); + } + } + } + writer.addSpacing(12); + } + } + document.save(baos); + return baos.toByteArray(); + + } catch (IOException e) { + throw new RuntimeException("Failed to generate PDF", e); + } + } + + private List splitText(String text, int maxCharsPerLine) { + if (text == null || text.isBlank()) { + return List.of(); + } + + List lines = new ArrayList<>(); + StringBuilder currentLine = new StringBuilder(); + + for (String word : text.split("\\s+")) { + if (currentLine.isEmpty()) { + currentLine.append(word); + continue; + } + + if (currentLine.length() + word.length() + 1 > maxCharsPerLine) { + lines.add(currentLine.toString()); + if (word.length() <= maxCharsPerLine) { + currentLine = new StringBuilder(word); + } else { + int start = 0; + while (start < word.length()) { + int end = Math.min(start + maxCharsPerLine, word.length()); + String chunk = word.substring(start, end); + if (end < word.length()) { + lines.add(chunk); + } else { + currentLine = new StringBuilder(chunk); + } + start = end; + } + } + } else { + if (!currentLine.isEmpty()) { + currentLine.append(" "); + } + currentLine.append(word); + } + } + + if (!currentLine.isEmpty()) { + lines.add(currentLine.toString()); + } + + return lines; + } + + public byte[] generateFullPdf(Long protocolId) { + return generatePdf(protocolId, createSystemAdminUser()); + } + + private UserEntity createSystemAdminUser() { + UserEntity systemUser = new UserEntity(); + systemUser.setName("system"); + systemUser.setRole(UserRole.ADMIN); + return systemUser; + } + + private static class PdfWriter implements AutoCloseable { + + private final PDDocument document; + private final PDType0Font regularFont; + private final PDType0Font boldFont; + + private PDPageContentStream content; + private float currentY; + + PdfWriter(PDDocument document) throws IOException { + this.document = document; + this.regularFont = loadFont(document, "/fonts/NotoSans-Regular.ttf"); + this.boldFont = loadFont(document, "/fonts/NotoSans-Bold.ttf"); + addNewPage(); + } + + private static PDType0Font loadFont(PDDocument document, String path) throws IOException { + InputStream inputStream = ProtocolPdfService.class.getResourceAsStream(path); + + if (inputStream == null) { + throw new IllegalStateException("Font file missing from classpath: " + path); + } + + try (inputStream) { + return PDType0Font.load(document, inputStream); + } + } + + void addNewPage() throws IOException { + if (content != null) { + content.endText(); + content.close(); + } + + PDPage page = new PDPage(); + document.addPage(page); + + content = new PDPageContentStream(document, page); + content.beginText(); + content.newLineAtOffset(MARGIN_LEFT, START_Y); + currentY = START_Y; + } + + void writeLine(String text, boolean bold, float fontSize) throws IOException { + float lineHeight = fontSize + 6; + if (currentY - lineHeight <= BOTTOM_MARGIN) { + addNewPage(); + } + + content.setFont(bold ? boldFont : regularFont, fontSize); + content.showText(text == null ? "" : text); + content.newLineAtOffset(0, -lineHeight); + currentY -= lineHeight; + } + + void addSpacing(float spacing) throws IOException { + if (currentY - spacing <= BOTTOM_MARGIN) { + addNewPage(); + return; + } + + content.newLineAtOffset(0, -spacing); + currentY -= spacing; + } + + @Override + public void close() throws IOException { + if (content != null) { + content.endText(); + content.close(); + } + } + } +} diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolRepository.java b/src/main/java/backendlab/team4you/protocol/ProtocolRepository.java index eabed8a5..842194b4 100644 --- a/src/main/java/backendlab/team4you/protocol/ProtocolRepository.java +++ b/src/main/java/backendlab/team4you/protocol/ProtocolRepository.java @@ -1,6 +1,9 @@ package backendlab.team4you.protocol; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -9,4 +12,7 @@ public interface ProtocolRepository extends JpaRepository { boolean existsByMeetingId(Long meetingId); Optional findByMeetingId(Long meetingId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithLockById(@Param("id")Long id); } diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolService.java b/src/main/java/backendlab/team4you/protocol/ProtocolService.java index fad98f3b..c0d3b13b 100644 --- a/src/main/java/backendlab/team4you/protocol/ProtocolService.java +++ b/src/main/java/backendlab/team4you/protocol/ProtocolService.java @@ -102,14 +102,20 @@ public Protocol updateParagraphDecision( String decisionText ) { Objects.requireNonNull(paragraphId, "paragraphId is required"); - Objects.requireNonNull(decisionType, "decisionType is required"); ProtocolParagraph paragraph = paragraphRepository.findById(paragraphId) .orElseThrow(() -> new ProtocolParagraphNotFoundException(paragraphId)); + Protocol protocol = protocolRepository.findWithLockById(paragraph.getProtocol().getId()) + .orElseThrow(() -> new ProtocolNotFoundException(paragraph.getProtocol().getId())); + + if (protocol.getArchivedPdfFile() != null) { + throw new ProtocolAlreadyArchivedException(protocol.getId()); + } + paragraph.updateDecision(decisionType, decisionText); - return paragraph.getProtocol(); + return protocol; } @Transactional(readOnly = true) diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolViewController.java b/src/main/java/backendlab/team4you/protocol/ProtocolViewController.java new file mode 100644 index 00000000..195dc56a --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolViewController.java @@ -0,0 +1,47 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.security.Principal; + +@Controller +@RequestMapping("/protocols") +public class ProtocolViewController { + + private final ProtocolService protocolService; + private final ProtocolViewService protocolViewService; + private final UserService userService; + + public ProtocolViewController( + ProtocolService protocolService, + ProtocolViewService protocolViewService, + UserService userService + ) { + this.protocolService = protocolService; + this.protocolViewService = protocolViewService; + this.userService = userService; + } + + @GetMapping("/{protocolId}") + public String viewProtocol( + @PathVariable Long protocolId, + Principal principal, + Model model + ) { + UserEntity currentUser = userService.getCurrentUser(principal); + + model.addAttribute("protocol", protocolService.getProtocol(protocolId)); + model.addAttribute( + "paragraphs", + protocolViewService.getParagraphsForViewer(protocolId, currentUser) + ); + + return "protocol-view"; + } +} diff --git a/src/main/java/backendlab/team4you/protocol/ProtocolViewService.java b/src/main/java/backendlab/team4you/protocol/ProtocolViewService.java new file mode 100644 index 00000000..f9350188 --- /dev/null +++ b/src/main/java/backendlab/team4you/protocol/ProtocolViewService.java @@ -0,0 +1,63 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.exceptions.ProtocolNotFoundException; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class ProtocolViewService { + private final ProtocolRepository protocolRepository; + + public ProtocolViewService(ProtocolRepository protocolRepository) { + this.protocolRepository = protocolRepository; + } + public List getParagraphsForViewer(Long protocolId, UserEntity viewer) { + Protocol protocol = protocolRepository.findById(protocolId) + .orElseThrow(() -> new ProtocolNotFoundException(protocolId)); + + return protocol.getParagraphs().stream() + .map(paragraph -> toViewDto(paragraph, viewer)) + .toList(); + } + + private ProtocolParagraphViewDto toViewDto(ProtocolParagraph paragraph, UserEntity viewer) { + CaseRecord caseRecord = paragraph.getCaseRecord(); + + boolean restricted = caseRecord.getConfidentialityLevel() == ConfidentialityLevel.CONFIDENTIAL; + boolean canViewDecision = !restricted + || isAdmin(viewer) + || isAssignedCaseOfficer(viewer, caseRecord); + + return new ProtocolParagraphViewDto( + paragraph.getId(), + paragraph.getHeading(), + caseRecord.getCaseNumber(), + restricted && !canViewDecision, + canViewDecision ? paragraph.getDecisionType() : null, + canViewDecision && paragraph.getDecisionType() != null + ? paragraph.getDecisionType().getLabel() + : null, + canViewDecision + ? paragraph.getDecisionText() + : "Beslutet omfattas av sekretess." + ); + } + + private boolean isAdmin(UserEntity viewer) { + return viewer != null && viewer.getRole() == UserRole.ADMIN; + } + + private boolean isAssignedCaseOfficer(UserEntity viewer, CaseRecord caseRecord) { + return viewer != null + && viewer.getRole() == UserRole.CASE_OFFICER + && caseRecord.getAssignedUser() != null + && caseRecord.getAssignedUser().getIdAsString().equals(viewer.getIdAsString()); + } +} diff --git a/src/main/java/backendlab/team4you/user/UserRepository.java b/src/main/java/backendlab/team4you/user/UserRepository.java index 161fe8f8..6d390858 100644 --- a/src/main/java/backendlab/team4you/user/UserRepository.java +++ b/src/main/java/backendlab/team4you/user/UserRepository.java @@ -28,4 +28,6 @@ public interface UserRepository extends JpaRepository { UserEntity findByDisplayName(String DisplayName); List findByRole(UserRole role); + + boolean existsByName(String name); } diff --git a/src/main/resources/db/migration/V24__archived_pdf_file_id.sql b/src/main/resources/db/migration/V24__archived_pdf_file_id.sql new file mode 100644 index 00000000..f7344343 --- /dev/null +++ b/src/main/resources/db/migration/V24__archived_pdf_file_id.sql @@ -0,0 +1,11 @@ +ALTER TABLE protocol + ADD COLUMN archived_pdf_file_id BIGINT; + +ALTER TABLE protocol + ADD CONSTRAINT fk_protocol_archived_pdf_file + FOREIGN KEY (archived_pdf_file_id) + REFERENCES case_file(id); + +ALTER TABLE protocol + ADD CONSTRAINT uk_protocol_archived_pdf_file + UNIQUE (archived_pdf_file_id); \ No newline at end of file diff --git a/src/main/resources/fonts/NotoSans-Bold.ttf b/src/main/resources/fonts/NotoSans-Bold.ttf new file mode 100644 index 00000000..07f0d257 Binary files /dev/null and b/src/main/resources/fonts/NotoSans-Bold.ttf differ diff --git a/src/main/resources/fonts/NotoSans-Regular.ttf b/src/main/resources/fonts/NotoSans-Regular.ttf new file mode 100644 index 00000000..4bac02f2 Binary files /dev/null and b/src/main/resources/fonts/NotoSans-Regular.ttf differ diff --git a/src/main/resources/templates/case-officer-layout.html b/src/main/resources/templates/case-officer-layout.html index 30d17265..9f8fa74f 100644 --- a/src/main/resources/templates/case-officer-layout.html +++ b/src/main/resources/templates/case-officer-layout.html @@ -9,6 +9,8 @@ + + diff --git a/src/main/resources/templates/dashboard-layout.html b/src/main/resources/templates/dashboard-layout.html index 95fa5449..bb82fc95 100644 --- a/src/main/resources/templates/dashboard-layout.html +++ b/src/main/resources/templates/dashboard-layout.html @@ -9,6 +9,8 @@ + + @@ -29,6 +31,22 @@ Dashboard Hem + +
  • + + + Handläggarvy + +
  • + +
  • + + + Till admin dashboard + +
  • +
  • @@ -65,6 +83,18 @@ Ärenden
  • + +
  • + + + Protokoll + +
  • +
  • diff --git a/src/main/resources/templates/fragments/admin-protocols.html b/src/main/resources/templates/fragments/admin-protocols.html index ba6e3bd9..b78344fe 100644 --- a/src/main/resources/templates/fragments/admin-protocols.html +++ b/src/main/resources/templates/fragments/admin-protocols.html @@ -97,6 +97,42 @@

    Protokoll

    Sessionssalen

    +
    + +
    + + Visa PDF + + + + +
    + + + +
    +
    + + + +
    +

    Paragrafer

    @@ -105,7 +141,7 @@

    Paragrafer

    Protokollet saknar paragrafer.

    -
    @@ -113,25 +149,30 @@

    Paragrafer

    Ärendenummer: - KS26-1 + KS26-1 - - Ärendebeskrivning - - -
    +
    Beslut: - Bifall -

    + Beslutet omfattas av sekretess. + + + + Bifall + + +

    Beslutstext

    -
    @@ -177,6 +218,12 @@

    Paragrafer

    Spara beslut
    + + +
    diff --git a/src/main/resources/templates/fragments/case-officer-sidenav.html b/src/main/resources/templates/fragments/case-officer-sidenav.html index 568c8995..b40af320 100644 --- a/src/main/resources/templates/fragments/case-officer-sidenav.html +++ b/src/main/resources/templates/fragments/case-officer-sidenav.html @@ -15,6 +15,23 @@
  • +
  • + + + Protokoll + +
  • + +
  • + + + Till dashboard + +
  • +
  • diff --git a/src/main/resources/templates/fragments/dashboard-protocols.html b/src/main/resources/templates/fragments/dashboard-protocols.html new file mode 100644 index 00000000..17d8f089 --- /dev/null +++ b/src/main/resources/templates/fragments/dashboard-protocols.html @@ -0,0 +1,95 @@ +
    + +

    Protokoll

    + +
    + +
    +

    Publicerade protokoll

    + +

    + Inga protokoll finns ännu. +

    + +
    +
    + +
    + +
    +

    Välj ett protokoll

    +

    Välj ett protokoll i listan.

    +
    + +
    +

    Protokoll

    + +

    + Diarium: + Kommunstyrelsen +

    + +

    + Sammanträde: + Sammanträde +

    + +

    + Datum: + + 2026-05-10 13:00 + +

    + +
    + +

    Paragrafer

    + +
    + +
    + § 1 Ärende + + + Ärendenummer: + KS26-1 + + +
    + Beslut: + + + 🔒 Beslutet omfattas av sekretess. + + + + Bifall + + +

    + Beslutstext +

    +
    +
    +
    +
    + +
    +
    +
    \ No newline at end of file diff --git a/src/main/resources/templates/fragments/side-navbar.html b/src/main/resources/templates/fragments/side-navbar.html index 6297ef13..81bc8bab 100644 --- a/src/main/resources/templates/fragments/side-navbar.html +++ b/src/main/resources/templates/fragments/side-navbar.html @@ -1,4 +1,7 @@ -
  • +
  • + + + Dashboard + +
  • + +
  • + + + Handläggarvy + +
  • +
  • diff --git a/src/main/resources/templates/protocol-view.html b/src/main/resources/templates/protocol-view.html new file mode 100644 index 00000000..512ab94c --- /dev/null +++ b/src/main/resources/templates/protocol-view.html @@ -0,0 +1,62 @@ + + + + + Protokoll + + + +

    Protokoll

    + +

    + Diarium: + Kommunstyrelsen +

    + +

    + Sammanträde: + Sammanträde +

    + +

    + Datum: + + 2026-04-27 13:00 + +

    + +
    + +

    Paragrafer

    + +
    +

    § 1 Ärende

    + +

    + Ärendenummer: + KS26-1 +

    + +

    + Beslut: + + + Beslutet omfattas av sekretess. + + + + Bifall + +

    + +

    + Beslutstext +

    + +
    +
    + + + \ No newline at end of file diff --git a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java index 9b0a298c..c2caa89a 100644 --- a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java +++ b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java @@ -677,4 +677,121 @@ void deleteFile_shouldThrowFileInUseException_whenFileIsUsedByMeetingAgendaDocum verify(caseFileRepository, never()).delete(any()); verify(s3Service, never()).deleteFile(anyString()); } + + @Test + @DisplayName("uploadGeneratedFile should upload generated file and save metadata") + void uploadGeneratedFile_shouldUploadGeneratedFileAndSaveMetadata() { + byte[] bytes = "pdf-content".getBytes(); + + when(caseRecordRepository.findByIdWithLock(1L)).thenReturn(Optional.of(caseRecord)); + when(caseFileAccessService.canUploadFile(actor, caseRecord, ConfidentialityLevel.OPEN)).thenReturn(true); + when(caseFileRepository.findTopByCaseRecordIdOrderByDocumentNumberDesc(1L)).thenReturn(Optional.empty()); + when(caseFileRepository.saveAndFlush(any(CaseFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + CaseFile result = caseFileService.uploadGeneratedFile( + 1L, + "protokoll-ks-2026-1.pdf", + "application/pdf", + bytes, + ConfidentialityLevel.OPEN, + actor + ); + + assertThat(result.getCaseRecord()).isEqualTo(caseRecord); + assertThat(result.getOriginalFilename()).isEqualTo("protokoll-ks-2026-1.pdf"); + assertThat(result.getContentType()).isEqualTo("application/pdf"); + assertThat(result.getSize()).isEqualTo(bytes.length); + assertThat(result.getDocumentNumber()).isEqualTo(1); + assertThat(result.getDocumentReference()).isEqualTo("KS26-1-1"); + assertThat(result.getUploadedAt()).isNotNull(); + assertThat(result.getS3Key()).contains("cases/1/"); + assertThat(result.getS3Key()).endsWith("-protokoll-ks-2026-1.pdf"); + + verify(s3Service).uploadFileIfAbsent( + startsWith("cases/1/"), + eq(bytes), + eq("application/pdf") + ); + } + + @Test + @DisplayName("uploadGeneratedFile should assign next document number") + void uploadGeneratedFile_shouldAssignNextDocumentNumber() { + byte[] bytes = "pdf-content".getBytes(); + + CaseFile existingFile = new CaseFile(); + existingFile.setDocumentNumber(3); + + when(caseRecordRepository.findByIdWithLock(1L)).thenReturn(Optional.of(caseRecord)); + when(caseFileAccessService.canUploadFile(actor, caseRecord, ConfidentialityLevel.OPEN)).thenReturn(true); + when(caseFileRepository.findTopByCaseRecordIdOrderByDocumentNumberDesc(1L)) + .thenReturn(Optional.of(existingFile)); + when(caseFileRepository.saveAndFlush(any(CaseFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + CaseFile result = caseFileService.uploadGeneratedFile( + 1L, + "protokoll.pdf", + "application/pdf", + bytes, + ConfidentialityLevel.OPEN, + actor + ); + + assertThat(result.getDocumentNumber()).isEqualTo(4); + assertThat(result.getDocumentReference()).isEqualTo("KS26-1-4"); + } + + @Test + @DisplayName("uploadGeneratedFile should throw FileTooLargeException when generated file is too large") + void uploadGeneratedFile_shouldThrowFileTooLargeException_whenGeneratedFileTooLarge() { + byte[] bytes = new byte[6 * 1024 * 1024]; + + when(caseRecordRepository.findByIdWithLock(1L)).thenReturn(Optional.of(caseRecord)); + when(caseFileAccessService.canUploadFile(actor, caseRecord, ConfidentialityLevel.OPEN)).thenReturn(true); + + assertThatThrownBy(() -> caseFileService.uploadGeneratedFile( + 1L, + "big.pdf", + "application/pdf", + bytes, + ConfidentialityLevel.OPEN, + actor + )) + .isInstanceOf(FileTooLargeException.class) + .hasMessageContaining("Filen är för stor."); + + verifyNoInteractions(caseFileRepository, s3Service); + } + + @Test + @DisplayName("uploadGeneratedFile should delete uploaded object when repository save fails") + void uploadGeneratedFile_shouldDeleteUploadedObjectWhenRepositorySaveFails() { + byte[] bytes = "pdf-content".getBytes(); + + when(caseRecordRepository.findByIdWithLock(1L)).thenReturn(Optional.of(caseRecord)); + when(caseFileAccessService.canUploadFile(actor, caseRecord, ConfidentialityLevel.OPEN)).thenReturn(true); + when(caseFileRepository.findTopByCaseRecordIdOrderByDocumentNumberDesc(1L)).thenReturn(Optional.empty()); + when(caseFileRepository.saveAndFlush(any(CaseFile.class))) + .thenThrow(new DataIntegrityViolationException("database failure")); + + assertThatThrownBy(() -> caseFileService.uploadGeneratedFile( + 1L, + "protokoll.pdf", + "application/pdf", + bytes, + ConfidentialityLevel.OPEN, + actor + )) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("database failure"); + + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + + verify(s3Service).uploadFileIfAbsent( + keyCaptor.capture(), + eq(bytes), + eq("application/pdf") + ); + verify(s3Service).deleteFile(keyCaptor.getValue()); + } } diff --git a/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java index f4c60f70..2eaffcfe 100644 --- a/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java +++ b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java @@ -5,6 +5,7 @@ import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; import backendlab.team4you.exceptions.*; +import backendlab.team4you.protocol.ProtocolRepository; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.junit.jupiter.api.BeforeEach; @@ -47,6 +48,9 @@ class MeetingServiceTest { @Mock private CaseFileRepository caseFileRepository; + @Mock + private ProtocolRepository protocolRepository; + @InjectMocks private MeetingService meetingService; @@ -160,7 +164,8 @@ void getMeetingById_shouldThrowMeetingNotFoundException_whenMeetingDoesNotExist( @Test @DisplayName("updateMeeting should update existing meeting") void updateMeeting_shouldUpdateExistingMeeting() { - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingRepository.save(any(Meeting.class))).thenAnswer(invocation -> invocation.getArgument(0)); Meeting updated = meetingService.updateMeeting( @@ -186,7 +191,8 @@ void updateMeeting_shouldUpdateExistingMeeting() { void addCaseRecordToMeeting_shouldCreateAgendaItemWithNextOrder() { when(caseRecord.getRegistry()).thenReturn(registry); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(caseRecordRepository.findById(100L)).thenReturn(Optional.of(caseRecord)); when(meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)).thenReturn(false); when(meetingAgendaItemRepository.countByMeeting(meeting)).thenReturn(2L); @@ -205,7 +211,8 @@ void addCaseRecordToMeeting_shouldCreateAgendaItemWithNextOrder() { void addCaseRecordToMeeting_shouldThrowDuplicateMeetingAgendaItemException_whenCaseRecordAlreadyExists() { when(caseRecord.getRegistry()).thenReturn(registry); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(caseRecordRepository.findById(100L)).thenReturn(Optional.of(caseRecord)); when(meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)).thenReturn(true); @@ -219,7 +226,8 @@ void addCaseRecordToMeeting_shouldThrowDuplicateMeetingAgendaItemException_whenC void addCaseRecordToMeeting_shouldThrowInvalidMeetingStateException_whenCaseRecordBelongsToDifferentRegistry() { when(otherRegistryCaseRecord.getRegistry()).thenReturn(otherRegistry); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(caseRecordRepository.findById(200L)).thenReturn(Optional.of(otherRegistryCaseRecord)); assertThatThrownBy(() -> meetingService.addCaseRecordToMeeting(10L, 200L)) @@ -236,7 +244,8 @@ void addDocumentToAgendaItem_shouldAddDocument_whenFileBelongsToSameCaseRecord() when(caseFile.getCaseRecord()).thenReturn(caseRecord); when(caseRecord.getId()).thenReturn(100L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); when(caseFileRepository.findByIdAndCaseRecordId(1000L, 100L)) .thenReturn(Optional.of(caseFile)); @@ -259,7 +268,8 @@ void addDocumentToAgendaItem_shouldThrowDuplicateMeetingAgendaDocumentException_ when(caseFile.getCaseRecord()).thenReturn(caseRecord); when(caseRecord.getId()).thenReturn(100L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); when(caseFileRepository.findByIdAndCaseRecordId(1000L, 100L)) .thenReturn(Optional.of(caseFile)); @@ -277,7 +287,8 @@ void addDocumentToAgendaItem_shouldThrowCaseFileNotFoundException_whenFileBelong setField(agendaItem, "id", 50L); when(caseRecord.getId()).thenReturn(100L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); assertThatThrownBy(() -> meetingService.addDocumentToAgendaItem(10L, 50L, 2000L)) @@ -297,7 +308,8 @@ void removeAgendaItem_shouldDeleteItemAndResequenceRemainingItems() { MeetingAgendaItem item3 = new MeetingAgendaItem(meeting, caseRecord, 3, null); setField(item3, "id", 13L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingAgendaItemRepository.findById(12L)).thenReturn(Optional.of(item2)); when(meetingAgendaItemRepository.findByMeetingOrderByAgendaOrderAsc(meeting)) .thenReturn(List.of(item1, item3)); @@ -318,7 +330,8 @@ void moveAgendaItemUp_shouldSwapAgendaOrderWithPreviousItem() { MeetingAgendaItem item2 = new MeetingAgendaItem(meeting, caseRecord, 2, null); setField(item2, "id", 12L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingAgendaItemRepository.findByIdAndMeeting(12L, meeting)).thenReturn(Optional.of(item2)); when(meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, 1)).thenReturn(Optional.of(item1)); @@ -338,7 +351,8 @@ void moveAgendaItemDown_shouldSwapAgendaOrderWithNextItem() { MeetingAgendaItem item3 = new MeetingAgendaItem(meeting, caseRecord, 3, null); setField(item3, "id", 13L); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); +when(protocolRepository.existsByMeetingId(10L)).thenReturn(false); when(meetingAgendaItemRepository.findByIdAndMeeting(12L, meeting)).thenReturn(Optional.of(item2)); when(meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, 3)).thenReturn(Optional.of(item3)); @@ -358,4 +372,26 @@ private void setField(Object target, String fieldName, Object value) { throw new RuntimeException(exception); } } + + @Test + @DisplayName("updateMeeting should throw MeetingHasProtocolException when meeting has protocol") + void updateMeeting_shouldThrowMeetingHasProtocolException_whenMeetingHasProtocol() { + when(meetingRepository.findByIdWithLock(10L)).thenReturn(Optional.of(meeting)); + when(protocolRepository.existsByMeetingId(10L)).thenReturn(true); + + assertThatThrownBy(() -> meetingService.updateMeeting( + 10L, + "Nytt mötesnamn", + LocalDateTime.of(2026, 5, 1, 9, 0), + LocalDateTime.of(2026, 5, 1, 11, 0), + "Nya salen", + "Nya anteckningar", + MeetingStatus.PREPARING + )) + .isInstanceOf(MeetingHasProtocolException.class); + + verify(meetingRepository).findByIdWithLock(10L); + verify(protocolRepository).existsByMeetingId(10L); + verify(meetingRepository, never()).save(any()); + } } diff --git a/src/test/java/backendlab/team4you/protocol/ProtocolArchiveServiceTest.java b/src/test/java/backendlab/team4you/protocol/ProtocolArchiveServiceTest.java new file mode 100644 index 00000000..9dfe72cc --- /dev/null +++ b/src/test/java/backendlab/team4you/protocol/ProtocolArchiveServiceTest.java @@ -0,0 +1,169 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.casefile.CaseFileService; +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordService; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.exceptions.ProtocolNotFoundException; +import backendlab.team4you.exceptions.ProtocolNotReadyForPdfException; +import backendlab.team4you.meeting.Meeting; +import backendlab.team4you.meeting.MeetingStatus; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ProtocolArchiveServiceTest { + + @Mock + private ProtocolRepository protocolRepository; + + @Mock + private ProtocolPdfService protocolPdfService; + + @Mock + private CaseRecordService caseRecordService; + + @Mock + private CaseFileService caseFileService; + + @InjectMocks + private ProtocolArchiveService protocolArchiveService; + + private Protocol protocol; + private Meeting meeting; + private Registry registry; + private CaseRecord annualCase; + private UserEntity currentUser; + + @BeforeEach + void setUp() { + registry = new Registry("Kommunstyrelsen", "KS"); + setField(registry, "id", 1L); + + meeting = new Meeting( + registry, + "KS april", + LocalDateTime.of(2026, 4, 27, 13, 0), + LocalDateTime.of(2026, 4, 27, 15, 0), + "Sessionssalen", + MeetingStatus.COMPLETED, + null + ); + + protocol = new Protocol(meeting, registry, "Protokoll - Kommunstyrelsen - 2026", 2026); + setField(protocol, "id", 1L); + + annualCase = mock(CaseRecord.class); + currentUser = mock(UserEntity.class); + } + + @Test + @DisplayName("archiveProtocolPdf should throw when protocol does not exist") + void archiveProtocolPdf_shouldThrow_whenProtocolNotFound() { + when(protocolRepository.findWithLockById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> protocolArchiveService.archiveProtocolPdf(1L, currentUser)) + .isInstanceOf(ProtocolNotFoundException.class); + } + + @Test + @DisplayName("archiveProtocolPdf should return existing file when already archived") + void archiveProtocolPdf_shouldReturnExistingFile_whenAlreadyArchived() { + CaseFile existingFile = mock(CaseFile.class); + protocol.setArchivedPdfFile(existingFile); + + when(protocolRepository.findWithLockById(1L)).thenReturn(Optional.of(protocol)); + + CaseFile result = protocolArchiveService.archiveProtocolPdf(1L, currentUser); + + assertThat(result).isSameAs(existingFile); + + verifyNoInteractions(protocolPdfService); + verifyNoInteractions(caseRecordService); + verifyNoInteractions(caseFileService); + } + + @Test + @DisplayName("archiveProtocolPdf should throw when protocol is not ready") + void archiveProtocolPdf_shouldThrow_whenProtocolNotReady() { + when(protocolRepository.findWithLockById(1L)).thenReturn(Optional.of(protocol)); + + assertThatThrownBy(() -> protocolArchiveService.archiveProtocolPdf(1L, currentUser)) + .isInstanceOf(ProtocolNotReadyForPdfException.class); + } + + @Test + @DisplayName("archiveProtocolPdf should archive pdf when protocol is ready") + void archiveProtocolPdf_shouldArchivePdf_whenReady() { + CaseRecord caseRecord = mock(CaseRecord.class); + + ProtocolParagraph paragraph = new ProtocolParagraph( + caseRecord, + 1L, + "§ 1 Ärende" + ); + paragraph.updateDecision( + ProtocolDecisionType.APPROVED, + "Kommunstyrelsen beslutar att bifalla ärendet." + ); + protocol.addParagraph(paragraph); + + when(protocolRepository.findWithLockById(1L)).thenReturn(Optional.of(protocol)); + + when(caseRecordService.findOrCreateAnnualProtocolCase(eq(registry), eq(2026), eq(currentUser))) + .thenReturn(annualCase); + + when(annualCase.getId()).thenReturn(20L); + + byte[] pdfBytes = "pdf".getBytes(); + when(protocolPdfService.generatePdf(eq(1L), any(UserEntity.class))) + .thenReturn(pdfBytes); + + CaseFile savedFile = mock(CaseFile.class); + when(caseFileService.uploadGeneratedFile( + eq(20L), + eq("protokoll-ks-2026-1.pdf"), + eq("application/pdf"), + eq(pdfBytes), + eq(ConfidentialityLevel.OPEN), + eq(currentUser) + )).thenReturn(savedFile); + + CaseFile result = protocolArchiveService.archiveProtocolPdf(1L, currentUser); + + assertThat(result).isSameAs(savedFile); + assertThat(protocol.getArchivedPdfFile()).isSameAs(savedFile); + + verify(protocolRepository).save(protocol); + verify(protocolPdfService).generatePdf(eq(1L), argThat(user -> + user != null && user.getRole() == UserRole.ADMIN + )); + } + + private void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/backendlab/team4you/protocol/ProtocolControllerTest.java b/src/test/java/backendlab/team4you/protocol/ProtocolControllerTest.java index 1ab9022e..42c01585 100644 --- a/src/test/java/backendlab/team4you/protocol/ProtocolControllerTest.java +++ b/src/test/java/backendlab/team4you/protocol/ProtocolControllerTest.java @@ -4,6 +4,9 @@ import backendlab.team4you.meeting.MeetingRepository; import backendlab.team4you.meeting.MeetingStatus; import backendlab.team4you.registry.Registry; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import backendlab.team4you.user.UserService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,6 +16,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.lang.reflect.Field; +import java.security.Principal; import java.time.LocalDateTime; import java.util.List; @@ -37,6 +41,12 @@ class ProtocolControllerTest { @MockitoBean private MeetingRepository meetingRepository; + @MockitoBean + private ProtocolViewService protocolViewService; + + @MockitoBean + private UserService userService; + @Test @WithMockUser(roles = "ADMIN") @DisplayName("GET /admin/protocols should return protocols fragment") @@ -63,6 +73,10 @@ void createProtocol_shouldReturnFragmentAndSuccessMessage() throws Exception { Registry registry = registry(1L, "Kommunstyrelsen", "KS"); Meeting meeting = meeting(10L, registry, "KS april"); Protocol protocol = protocol(100L, meeting, registry); + UserEntity currentUser = adminUser(); + + when(userService.getCurrentUser(any())).thenReturn(currentUser); + when(protocolViewService.getParagraphsForViewer(100L, currentUser)).thenReturn(List.of()); when(protocolService.createProtocolForCompletedMeeting(10L)).thenReturn(protocol); when(meetingRepository.findCompletedMeetingsWithoutProtocol()).thenReturn(List.of()); @@ -74,9 +88,11 @@ void createProtocol_shouldReturnFragmentAndSuccessMessage() throws Exception { .andExpect(status().isOk()) .andExpect(view().name("fragments/admin-protocols :: content")) .andExpect(model().attribute("successMessage", "Protokoll skapades.")) - .andExpect(model().attribute("selectedProtocol", protocol)); + .andExpect(model().attribute("selectedProtocol", protocol)) + .andExpect(model().attributeExists("paragraphViews")); verify(protocolService).createProtocolForCompletedMeeting(10L); + verify(protocolViewService).getParagraphsForViewer(100L, currentUser); } @Test @@ -86,6 +102,10 @@ void viewProtocol_shouldReturnFragmentWithSelectedProtocol() throws Exception { Registry registry = registry(1L, "Kommunstyrelsen", "KS"); Meeting meeting = meeting(10L, registry, "KS april"); Protocol protocol = protocol(100L, meeting, registry); + UserEntity currentUser = adminUser(); + + when(userService.getCurrentUser(any())).thenReturn(currentUser); + when(protocolViewService.getParagraphsForViewer(100L, currentUser)).thenReturn(List.of()); when(protocolService.getProtocol(100L)).thenReturn(protocol); when(meetingRepository.findCompletedMeetingsWithoutProtocol()).thenReturn(List.of()); @@ -95,7 +115,8 @@ void viewProtocol_shouldReturnFragmentWithSelectedProtocol() throws Exception { .header("HX-Request", "true")) .andExpect(status().isOk()) .andExpect(view().name("fragments/admin-protocols :: content")) - .andExpect(model().attribute("selectedProtocol", protocol)); + .andExpect(model().attribute("selectedProtocol", protocol)) + .andExpect(model().attributeExists("paragraphViews")); } @Test @@ -105,6 +126,10 @@ void updateParagraphDecision_shouldReturnFragmentAndSuccessMessage() throws Exce Registry registry = registry(1L, "Kommunstyrelsen", "KS"); Meeting meeting = meeting(10L, registry, "KS april"); Protocol protocol = protocol(100L, meeting, registry); + UserEntity currentUser = adminUser(); + + when(userService.getCurrentUser(any())).thenReturn(currentUser); + when(protocolViewService.getParagraphsForViewer(100L, currentUser)).thenReturn(List.of()); when(protocolService.updateParagraphDecision( 200L, @@ -123,13 +148,15 @@ void updateParagraphDecision_shouldReturnFragmentAndSuccessMessage() throws Exce .andExpect(status().isOk()) .andExpect(view().name("fragments/admin-protocols :: content")) .andExpect(model().attribute("successMessage", "Beslut sparades.")) - .andExpect(model().attribute("selectedProtocol", protocol)); + .andExpect(model().attribute("selectedProtocol", protocol)) + .andExpect(model().attributeExists("paragraphViews")); verify(protocolService).updateParagraphDecision( 200L, ProtocolDecisionType.REJECTED, "Kommunstyrelsen beslutar att avslå ärendet." ); + verify(protocolViewService).getParagraphsForViewer(100L, currentUser); } @Test @@ -191,4 +218,11 @@ private void setField(Object target, String fieldName, Object value) { throw new RuntimeException(exception); } } + + private UserEntity adminUser() { + UserEntity user = new UserEntity(); + user.setName("admin"); + user.setRole(UserRole.ADMIN); + return user; + } } diff --git a/src/test/java/backendlab/team4you/protocol/ProtocolPdfServiceTest.java b/src/test/java/backendlab/team4you/protocol/ProtocolPdfServiceTest.java new file mode 100644 index 00000000..c9b5be89 --- /dev/null +++ b/src/test/java/backendlab/team4you/protocol/ProtocolPdfServiceTest.java @@ -0,0 +1,156 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.exceptions.ProtocolNotFoundException; +import backendlab.team4you.exceptions.ProtocolNotReadyForPdfException; +import backendlab.team4you.meeting.Meeting; +import backendlab.team4you.meeting.MeetingStatus; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProtocolPdfServiceTest { + + @Mock + private ProtocolRepository protocolRepository; + private UserEntity viewer; + + @Mock + private ProtocolViewService protocolViewService; + + private ProtocolPdfService protocolPdfService; + + private Registry registry; + private Meeting meeting; + private CaseRecord caseRecord; + + @BeforeEach + void setUp() { + protocolPdfService = new ProtocolPdfService(protocolRepository, protocolViewService); + + registry = new Registry("Kommunstyrelsen", "KS"); + setField(registry, "id", 1L); + + meeting = new Meeting( + registry, + "Kommunstyrelsen april", + LocalDateTime.of(2026, 4, 27, 13, 0), + LocalDateTime.of(2026, 4, 27, 15, 0), + "Sessionssalen", + MeetingStatus.COMPLETED, + "Anteckning" + ); + setField(meeting, "id", 10L); + + caseRecord = mock(CaseRecord.class); + + viewer = new UserEntity(); + viewer.setName("admin"); + viewer.setRole(UserRole.ADMIN); + } + + @Test + @DisplayName("generatePdf should throw ProtocolNotFoundException when protocol does not exist") + void generatePdf_shouldThrowProtocolNotFoundException_whenProtocolDoesNotExist() { + when(protocolRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> protocolPdfService.generatePdf(999L,viewer)) + .isInstanceOf(ProtocolNotFoundException.class); + } + + @Test + @DisplayName("generatePdf should throw ProtocolNotReadyForPdfException when protocol is not ready") + void generatePdf_shouldThrowProtocolNotReadyForPdfException_whenProtocolIsNotReady() { + Protocol protocol = new Protocol( + meeting, + registry, + "Protokoll - Kommunstyrelsen - 2026", + 2026 + ); + setField(protocol, "id", 1L); + + ProtocolParagraph paragraph = new ProtocolParagraph( + caseRecord, + 1L, + "§ 1 Provärendet" + ); + protocol.addParagraph(paragraph); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + + assertThatThrownBy(() -> protocolPdfService.generatePdf(1L, viewer)) + .isInstanceOf(ProtocolNotReadyForPdfException.class) + .hasMessage("Alla paragrafer måste ha beslut och beslutstext innan PDF kan skapas."); + } + + @Test + @DisplayName("generatePdf should return pdf bytes when protocol is ready") + void generatePdf_shouldReturnPdfBytes_whenProtocolIsReady() { + + Protocol protocol = new Protocol( + meeting, + registry, + "Protokoll - Kommunstyrelsen - 2026", + 2026 + ); + setField(protocol, "id", 1L); + + ProtocolParagraph paragraph = new ProtocolParagraph( + caseRecord, + 1L, + "§ 1 Provärendet" + ); + paragraph.updateDecision( + ProtocolDecisionType.APPROVED, + "Kommunstyrelsen beslutar att bifalla ärendet." + ); + protocol.addParagraph(paragraph); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + when(protocolViewService.getParagraphsForViewer(1L, viewer)) + .thenReturn(List.of(new ProtocolParagraphViewDto( + 1L, + "§ 1 Provärendet", + "KS26-1", + false, + ProtocolDecisionType.APPROVED, + "Bifall", + "Kommunstyrelsen beslutar att bifalla ärendet." + ))); + + byte[] result = protocolPdfService.generatePdf(1L, viewer); + + assertThat(result).isNotEmpty(); + assertThat(result[0]).isEqualTo((byte) '%'); + assertThat(result[1]).isEqualTo((byte) 'P'); + assertThat(result[2]).isEqualTo((byte) 'D'); + assertThat(result[3]).isEqualTo((byte) 'F'); + } + + private void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/backendlab/team4you/protocol/ProtocolServiceTest.java b/src/test/java/backendlab/team4you/protocol/ProtocolServiceTest.java index bb7daa6c..de824d30 100644 --- a/src/test/java/backendlab/team4you/protocol/ProtocolServiceTest.java +++ b/src/test/java/backendlab/team4you/protocol/ProtocolServiceTest.java @@ -1,10 +1,8 @@ package backendlab.team4you.protocol; +import backendlab.team4you.casefile.CaseFile; import backendlab.team4you.caserecord.CaseRecord; -import backendlab.team4you.exceptions.InvalidMeetingStateException; -import backendlab.team4you.exceptions.MeetingNotFoundException; -import backendlab.team4you.exceptions.ProtocolAlreadyExistsException; -import backendlab.team4you.exceptions.ProtocolParagraphNotFoundException; +import backendlab.team4you.exceptions.*; import backendlab.team4you.meeting.Meeting; import backendlab.team4you.meeting.MeetingAgendaItem; import backendlab.team4you.meeting.MeetingRepository; @@ -173,11 +171,14 @@ void createProtocolForCompletedMeeting_shouldThrowMeetingNotFoundException_whenM @DisplayName("updateParagraphDecision should update decision") void updateParagraphDecision_shouldUpdateDecision() { Protocol protocol = new Protocol(completedMeeting, registry, "Protokoll - Kommunstyrelsen - 2026", 2026); + setField(protocol, "id", 1L); + ProtocolParagraph paragraph = new ProtocolParagraph(firstCaseRecord, 1L, "§ 1 Första ärendet"); protocol.addParagraph(paragraph); setField(paragraph, "id", 100L); when(paragraphRepository.findById(100L)).thenReturn(Optional.of(paragraph)); + when(protocolRepository.findWithLockById(1L)).thenReturn(Optional.of(protocol)); Protocol result = protocolService.updateParagraphDecision( 100L, @@ -240,4 +241,32 @@ private void setField(Object target, String fieldName, Object value) { throw new RuntimeException(exception); } } + + @Test + @DisplayName("updateParagraphDecision should throw ProtocolAlreadyArchivedException when protocol is archived") + void updateParagraphDecision_shouldThrowProtocolAlreadyArchivedException_whenProtocolIsArchived() { + Protocol protocol = new Protocol(completedMeeting, registry, "Protokoll - Kommunstyrelsen - 2026", 2026); + setField(protocol, "id", 1L); + + ProtocolParagraph paragraph = new ProtocolParagraph(firstCaseRecord, 1L, "§ 1 Första ärendet"); + protocol.addParagraph(paragraph); + setField(paragraph, "id", 100L); + + CaseFile archivedPdfFile = mock(CaseFile.class); + protocol.setArchivedPdfFile(archivedPdfFile); + + when(paragraphRepository.findById(100L)).thenReturn(Optional.of(paragraph)); + when(protocolRepository.findWithLockById(1L)).thenReturn(Optional.of(protocol)); + + assertThatThrownBy(() -> protocolService.updateParagraphDecision( + 100L, + ProtocolDecisionType.APPROVED, + "Kommunstyrelsen beslutar att bifalla ärendet." + )) + .isInstanceOf(ProtocolAlreadyArchivedException.class) + .hasMessage("Protokollet är redan arkiverat och kan inte längre ändras."); + + assertThat(paragraph.getDecisionType()).isNull(); + assertThat(paragraph.getDecisionText()).isNull(); + } } diff --git a/src/test/java/backendlab/team4you/protocol/ProtocolViewServiceTest.java b/src/test/java/backendlab/team4you/protocol/ProtocolViewServiceTest.java new file mode 100644 index 00000000..cd2e5778 --- /dev/null +++ b/src/test/java/backendlab/team4you/protocol/ProtocolViewServiceTest.java @@ -0,0 +1,231 @@ +package backendlab.team4you.protocol; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseStatus; +import backendlab.team4you.common.ConfidentialityLevel; +import backendlab.team4you.exceptions.ProtocolNotFoundException; +import backendlab.team4you.meeting.Meeting; +import backendlab.team4you.meeting.MeetingStatus; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.user.UserEntity; +import backendlab.team4you.user.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProtocolViewServiceTest { + + @Mock + private ProtocolRepository protocolRepository; + + private ProtocolViewService protocolViewService; + + private Registry registry; + private Meeting meeting; + private UserEntity admin; + private UserEntity assignedCaseOfficer; + private UserEntity otherCaseOfficer; + private UserEntity normalUser; + + @BeforeEach + void setUp() { + protocolViewService = new ProtocolViewService(protocolRepository); + + registry = new Registry("Kommunstyrelsen", "KS"); + setField(registry, "id", 1L); + + meeting = new Meeting( + registry, + "KS april", + LocalDateTime.of(2026, 4, 27, 13, 0), + LocalDateTime.of(2026, 4, 27, 15, 0), + "Sessionssalen", + MeetingStatus.COMPLETED, + null + ); + + admin = user("admin-id", "admin", UserRole.ADMIN); + assignedCaseOfficer = user("officer-1", "caseofficer1", UserRole.CASE_OFFICER); + otherCaseOfficer = user("officer-2", "caseofficer2", UserRole.CASE_OFFICER); + normalUser = user("user-id", "user", UserRole.USER); + } + + @Test + @DisplayName("should show confidential decision to admin") + void shouldShowConfidentialDecisionToAdmin() { + Protocol protocol = createProtocolWithCase( + ConfidentialityLevel.CONFIDENTIAL, + assignedCaseOfficer + ); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + + List result = + protocolViewService.getParagraphsForViewer(1L, admin); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().decisionRestricted()).isFalse(); + assertThat(result.getFirst().decisionLabel()).isEqualTo("Bifall"); + assertThat(result.getFirst().decisionText()) + .isEqualTo("Kommunstyrelsen beslutar att bifalla ärendet."); + } + + @Test + @DisplayName("should show confidential decision to assigned case officer") + void shouldShowConfidentialDecisionToAssignedCaseOfficer() { + Protocol protocol = createProtocolWithCase( + ConfidentialityLevel.CONFIDENTIAL, + assignedCaseOfficer + ); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + + List result = + protocolViewService.getParagraphsForViewer(1L, assignedCaseOfficer); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().decisionRestricted()).isFalse(); + assertThat(result.getFirst().decisionLabel()).isEqualTo("Bifall"); + assertThat(result.getFirst().decisionText()) + .isEqualTo("Kommunstyrelsen beslutar att bifalla ärendet."); + } + + @Test + @DisplayName("should hide confidential decision from other case officer") + void shouldHideConfidentialDecisionFromOtherCaseOfficer() { + Protocol protocol = createProtocolWithCase( + ConfidentialityLevel.CONFIDENTIAL, + assignedCaseOfficer + ); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + + List result = + protocolViewService.getParagraphsForViewer(1L, otherCaseOfficer); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().decisionRestricted()).isTrue(); + assertThat(result.getFirst().decisionLabel()).isNull(); + assertThat(result.getFirst().decisionText()) + .isEqualTo("Beslutet omfattas av sekretess."); + } + + @Test + @DisplayName("should hide confidential decision from normal user") + void shouldHideConfidentialDecisionFromNormalUser() { + Protocol protocol = createProtocolWithCase( + ConfidentialityLevel.CONFIDENTIAL, + assignedCaseOfficer + ); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + + List result = + protocolViewService.getParagraphsForViewer(1L, normalUser); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().decisionRestricted()).isTrue(); + assertThat(result.getFirst().decisionLabel()).isNull(); + assertThat(result.getFirst().decisionText()) + .isEqualTo("Beslutet omfattas av sekretess."); + } + + @Test + @DisplayName("should show open decision to normal user") + void shouldShowOpenDecisionToNormalUser() { + Protocol protocol = createProtocolWithCase( + ConfidentialityLevel.OPEN, + assignedCaseOfficer + ); + + when(protocolRepository.findById(1L)).thenReturn(Optional.of(protocol)); + + List result = + protocolViewService.getParagraphsForViewer(1L, normalUser); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().decisionRestricted()).isFalse(); + assertThat(result.getFirst().decisionLabel()).isEqualTo("Bifall"); + assertThat(result.getFirst().decisionText()) + .isEqualTo("Kommunstyrelsen beslutar att bifalla ärendet."); + } + + @Test + @DisplayName("should throw ProtocolNotFoundException when protocol does not exist") + void shouldThrowProtocolNotFoundException_whenProtocolDoesNotExist() { + when(protocolRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> protocolViewService.getParagraphsForViewer(999L, normalUser)) + .isInstanceOf(ProtocolNotFoundException.class); + } + + private Protocol createProtocolWithCase( + ConfidentialityLevel confidentialityLevel, + UserEntity assignedUser + ) { + CaseRecord caseRecord = new CaseRecord( + registry, + "Provärendet", + "Beskrivning", + CaseStatus.OPEN, + admin, + assignedUser, + confidentialityLevel, + LocalDateTime.of(2026, 4, 1, 9, 0) + ); + setField(caseRecord, "id", 100L); + caseRecord.setCaseNumber("KS26-1"); + + Protocol protocol = new Protocol( + meeting, + registry, + "Protokoll - Kommunstyrelsen - 2026", + 2026 + ); + setField(protocol, "id", 1L); + + ProtocolParagraph paragraph = new ProtocolParagraph( + caseRecord, + 1L, + "§ 1 Provärendet" + ); + paragraph.updateDecision( + ProtocolDecisionType.APPROVED, + "Kommunstyrelsen beslutar att bifalla ärendet." + ); + + protocol.addParagraph(paragraph); + + return protocol; + } + + private UserEntity user(String id, String name, UserRole role) { + UserEntity user = new UserEntity(); + setField(user, "id", id); + user.setName(name); + user.setRole(role); + return user; + } + + private void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +}