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
+
+
+
+ PDF redan sparad i årsärende. Protokollet är låst för ändringar.
+
+
+
+
+
+
+ Alla paragrafer måste ha beslut och beslutstext innan PDF kan skapas.
+
+
+
+
Paragrafer
@@ -105,7 +141,7 @@ Paragrafer
Protokollet saknar paragrafer.
-