Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e0f23e1
fixed excepted success-message after changes in the controller
MartinStenhagen Apr 27, 2026
01336d3
Merge branch 'main' into feature/protocol-pdf-export
MartinStenhagen Apr 27, 2026
3488580
Added dependency for pdfbox and basic ProtocolPdfService and Protocol…
MartinStenhagen Apr 27, 2026
2a0283b
Added basic ui to generate pdf
MartinStenhagen Apr 27, 2026
cd872f3
reworked ProtocolPdfService to use actual data from a protocol
MartinStenhagen Apr 27, 2026
4ff3667
reworked CaseRecordService/Repo for protocol pdf-generation
MartinStenhagen Apr 27, 2026
4f3aba2
reworked CaseRecordService/Repo for protocol pdf-generation and added…
MartinStenhagen Apr 27, 2026
e3725ec
reworked CaseRecordService/Repo for protocol pdf-generation and added…
MartinStenhagen Apr 27, 2026
ca3fb5e
Added checks for decisions i all agendaitems before a protocol can be…
MartinStenhagen Apr 27, 2026
ad42b11
Added checks that prevents updates to decisions if relevant protocol …
MartinStenhagen Apr 27, 2026
74dc89c
Added exception handling
MartinStenhagen Apr 27, 2026
d559693
Added new tests
MartinStenhagen Apr 27, 2026
122d6cd
Added: Completed meetings cannot be altered after a protocol has been…
MartinStenhagen Apr 27, 2026
19fd9a1
some changes
MartinStenhagen Apr 27, 2026
cee5c3c
UI+PDF classified fixes
MartinStenhagen Apr 27, 2026
a4674ed
some more classified fixes
MartinStenhagen Apr 27, 2026
dee73b3
backend and ui for viewing protocol depending on access-level
MartinStenhagen Apr 27, 2026
ba41886
ui for case-officer etc
MartinStenhagen Apr 27, 2026
5b2d728
Updated tests to match current code
MartinStenhagen Apr 27, 2026
092b06d
Merge branch 'main' into feature/protocol-pdf-export
MartinStenhagen Apr 27, 2026
cbfa13d
fixed coderabbit feedback
MartinStenhagen Apr 27, 2026
0bca7bf
fixed coderabbit feedback
MartinStenhagen Apr 28, 2026
dda7fdf
fixed more coderabbit feedback
MartinStenhagen Apr 28, 2026
29d7c89
fixed coderabbit feedback: Page-break check should reserve space for …
MartinStenhagen Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.7</version>
</dependency>

</dependencies>

Expand Down
133 changes: 75 additions & 58 deletions src/main/java/backendlab/team4you/Team4youApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 + ")");
}
}
68 changes: 68 additions & 0 deletions src/main/java/backendlab/team4you/casefile/CaseFileService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
MartinStenhagen marked this conversation as resolved.

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);
Comment thread
MartinStenhagen marked this conversation as resolved.

} 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,6 +21,12 @@ public interface CaseRecordRepository extends JpaRepository<CaseRecord, Long> {
Optional<CaseRecord> 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<CaseRecord> findByAssignedUserId(String officerId, Pageable pageable);

Optional<CaseRecord> findByRegistryAndTitle(Registry registry, String title);
}
84 changes: 62 additions & 22 deletions src/main/java/backendlab/team4you/caserecord/CaseRecordService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
}
Comment thread
MartinStenhagen marked this conversation as resolved.

private String normalizeStatus(String status) {
if (status == null || status.isBlank()) {
throw new IllegalArgumentException("status is required");
Expand All @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading