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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ out/

### VS Code ###
.vscode/
target/
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
</properties>
<dependencies>
<dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Expand Down Expand Up @@ -71,6 +72,10 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@


import jakarta.persistence.*;
import org.example.crimearchive.audit.Auditable;

@Entity
@Table(name = "golare")
public class Golare {
public class Golare extends Auditable {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,33 @@
package org.example.crimearchive.Entity.försvare;
import jakarta.persistence.*;
import org.example.crimearchive.audit.Auditable;
import java.time.LocalDate;
import java.time.ZonedDateTime;

public class Advocat {
@Entity
@Table(name = "advocat")
public class Advocat extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Long id here vs long id in DTOAdvocat — auto-unboxing NPE risk.

Entity uses boxed Long id (correct for JPA, since null indicates "not yet persisted"), but DTOAdvocat (src/main/java/org/example/crimearchive/dto/defense/DTOAdvocat.java line 8) declares long id (primitive). If a mapper ever copies entity.getId() into the DTO before the entity is flushed/saved, auto-unboxing null → long will throw NullPointerException. Recommend changing DTOAdvocat.id to Long for consistency, or guarantee mappers only run post-persist.

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

In `@src/main/java/org/example/crimearchive/Entity/försvare/Advocat.java` at line
12, The DTO uses primitive long for id while the entity Advocat defines private
Long id, risking NullPointerException on auto-unboxing if a mapper reads a null
entity id; change DTOAdvocat.id from primitive long to boxed Long and update its
getter/setter/constructors/builders or any code that references
DTOAdvocat.getId()/setId(Long) so types match, or alternatively ensure all
mappers only map after persist—but the recommended fix is to change DTOAdvocat's
id type to Long and update related methods and mapping calls accordingly.


@Column(nullable = false)
private String email;

@Column(nullable = false)
private String company;

@Column(nullable = false)
private String telephone;

@Column(nullable = false)
private String name;

private String knumber;

private LocalDate appointedTime;

private ZonedDateTime crimeTime;
Comment on lines +26 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent column nullability declarations.

email, company, telephone, name are explicitly @Column(nullable = false), but knumber, appointedTime, and crimeTime carry no @Column at all and so default to nullable. That mismatches DTOCreateAdvocat, where KNumber, appointedTime, and crimeTime are all marked @NotBlank/@NotNull (src/main/java/org/example/crimearchive/dto/defense/DTOCreateAdvocat.java lines 18–23) — i.e., the API contract says these are required, but the schema permits NULL.

Decide which is authoritative and align them (typically: add @Column(nullable = false) to match the validation contract so the DDL enforces it as well).

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

In `@src/main/java/org/example/crimearchive/Entity/försvare/Advocat.java` around
lines 26 - 30, The fields knumber, appointedTime, and crimeTime on class Advocat
currently default to nullable, but DTOCreateAdvocat marks
KNumber/appointedTime/crimeTime as required; make the schema match the
validation by adding `@Column`(nullable = false) to the Advocat fields (knumber,
appointedTime, crimeTime) and ensure the corresponding imports
(javax.persistence.Column) are present; if you prefer the DTO to be
authoritative, instead relax DTOCreateAdvocat, but consistency must be achieved
between Advocat and DTOCreateAdvocat.


// Getters and Setters if needed, or use Lombok if available

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing getters/setters / accessors — entity is effectively unusable.

The trailing comment // Getters and Setters if needed, or use Lombok if available reads as a TODO. Without accessors (or Lombok @Getter/@Setter/@Data), this entity cannot be populated from DTOCreateAdvocat/DTOUpdateAdvocat nor projected back into DTOAdvocat by any conventional mapper, controller, or Jackson-based serializer. JPA itself can use field access via reflection, but the rest of the application stack typically cannot.

Either generate explicit getters/setters, or add Lombok annotations (and confirm Lombok is on the classpath in pom.xml). Also ensure a no-arg constructor remains accessible — JPA requires it, and adding any explicit constructor here would silently remove the default.

Want me to generate a Lombok-annotated version (or plain getters/setters) and verify Lombok is configured in pom.xml?

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

In `@src/main/java/org/example/crimearchive/Entity/försvare/Advocat.java` at line
32, The Advocat entity class lacks accessor methods or Lombok annotations,
making it unusable for mapping/serialization; add either explicit public getters
and setters for all persistent fields in class Advocat (and keep/ensure a public
no-arg constructor) or annotate the class with Lombok annotations such as
`@Getter`, `@Setter` and `@NoArgsConstructor/`@AllArgsConstructor (on Advocat) and
verify Lombok is declared in pom.xml so DTOCreateAdvocat/DTOUpdateAdvocat and
Jackson/mappers can populate/provide values to the entity; ensure any added
constructors do not remove the no-arg constructor required by JPA.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@


import jakarta.persistence.*;
import org.example.crimearchive.audit.Auditable;

@Entity
@Table(name = "Åklagare")
public class Åklagare {
public class Åklagare extends Auditable {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down
54 changes: 54 additions & 0 deletions src/main/java/org/example/crimearchive/audit/Auditable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.example.crimearchive.audit;


import jakarta.persistence.*;
import org.hibernate.envers.Audited;
import org.springframework.data.annotation.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Audited
public abstract class Auditable {

@CreatedBy
protected String createdBy;

@CreatedDate
protected LocalDateTime createdDate;

@LastModifiedBy
protected String lastModifiedBy;

@LastModifiedDate
protected LocalDateTime lastModifiedDate;

// Getters och Setters

public String getCreatedBy() {
return createdBy;
}
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedDate() {
return createdDate;
}
public void setCreatedDate(LocalDateTime createdDate) {
this.createdDate = createdDate;
}
public String getLastModifiedBy() {
return lastModifiedBy;
}
public void setLastModifiedBy(String lastModifiedBy) {
this.lastModifiedBy = lastModifiedBy;
}
public LocalDateTime getLastModifiedDate() {
return lastModifiedDate;
}
public void setLastModifiedDate(LocalDateTime lastModifiedDate) {
this.lastModifiedDate = lastModifiedDate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ public enum CaseEventType {
HANDLER_ASSIGNED,
HANDLER_REMOVED,
DOCUMENT_UPLOADED,
COMMENT_ADDED
COMMENT_ADDED,
ROLE_CHANGED
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import org.example.crimearchive.permissions.PermissionService;
import org.example.crimearchive.polis.Account;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.List;

Expand Down Expand Up @@ -72,6 +74,13 @@ public ResponseEntity<List<CaseEventResponse>> getEvents(@PathVariable String ca
return ResponseEntity.ok(events);
}

@GetMapping(value = "/{caseNumber}/events/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents(@PathVariable String caseNumber,
@AuthenticationPrincipal Account user) {
requireAccess(caseNumber, user);
return lifecycleService.subscribe(caseNumber);
}

private void requireAccess(String caseNumber, Account user) {
if (!permissionService.canAccessCase(caseNumber, user)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Åtkomst nekad till ärende: " + caseNumber);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

@Service
public class CaseLifecycleService {
Expand All @@ -14,6 +19,8 @@ public class CaseLifecycleService {
private final CaseEventRepository eventRepository;
private final CaseCommentRepository commentRepository;

private final ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>> emitters = new ConcurrentHashMap<>();

public CaseLifecycleService(CasesRepository casesRepository,
CaseEventRepository eventRepository,
CaseCommentRepository commentRepository) {
Expand All @@ -22,16 +29,25 @@ public CaseLifecycleService(CasesRepository casesRepository,
this.commentRepository = commentRepository;
}

public SseEmitter subscribe(String caseNumber) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
emitters.computeIfAbsent(caseNumber, k -> new CopyOnWriteArrayList<>()).add(emitter);
emitter.onCompletion(() -> removeEmitter(caseNumber, emitter));
emitter.onTimeout(() -> removeEmitter(caseNumber, emitter));
emitter.onError(e -> removeEmitter(caseNumber, emitter));
return emitter;
}

@Transactional
public void initCase(Cases cases, String performedBy) {
cases.setStatus(CaseStatus.OPEN);
eventRepository.save(new CaseEvent(cases, CaseEventType.CASE_CREATED,
saveAndBroadcast(new CaseEvent(cases, CaseEventType.CASE_CREATED,
"Ärende " + cases.getCaseNumber() + " skapades", performedBy));
}

@Transactional
public void onHandlerAssigned(Cases cases, String handlerName, String performedBy) {
eventRepository.save(new CaseEvent(cases, CaseEventType.HANDLER_ASSIGNED,
saveAndBroadcast(new CaseEvent(cases, CaseEventType.HANDLER_ASSIGNED,
"Handläggare " + handlerName + " tilldelades ärendet", performedBy));
if (cases.getStatus() == CaseStatus.OPEN) {
updateStatus(cases, CaseStatus.ASSIGNED, performedBy);
Expand All @@ -40,7 +56,7 @@ public void onHandlerAssigned(Cases cases, String handlerName, String performedB

@Transactional
public void onHandlerRemoved(Cases cases, String handlerName, String performedBy) {
eventRepository.save(new CaseEvent(cases, CaseEventType.HANDLER_REMOVED,
saveAndBroadcast(new CaseEvent(cases, CaseEventType.HANDLER_REMOVED,
"Handläggare " + handlerName + " togs bort från ärendet", performedBy));
// Only revert to OPEN from ASSIGNED — later states (IN_PROGRESS, CLOSED) are not disturbed by handler removal.
if (cases.getAccounts().isEmpty() && cases.getStatus() == CaseStatus.ASSIGNED) {
Expand All @@ -50,10 +66,19 @@ public void onHandlerRemoved(Cases cases, String handlerName, String performedBy

@Transactional
public void onDocumentUploaded(Cases cases, String filename, String performedBy) {
eventRepository.save(new CaseEvent(cases, CaseEventType.DOCUMENT_UPLOADED,
saveAndBroadcast(new CaseEvent(cases, CaseEventType.DOCUMENT_UPLOADED,
"Dokument laddades upp: " + filename, performedBy));
}

@Transactional
public void onRoleChanged(Long accountId, String username, List<String> oldRoles, List<String> newRoles, String performedBy) {
List<Cases> cases = casesRepository.findByAccountsId(accountId);
String description = "Roller för " + username + " ändrades från [" + String.join(", ", oldRoles) + "] till [" + String.join(", ", newRoles) + "]";
for (Cases c : cases) {
saveAndBroadcast(new CaseEvent(c, CaseEventType.ROLE_CHANGED, description, performedBy));
}
}

@Transactional
public CaseComment addComment(String caseNumber, String content, String author) {
if (content == null || content.isBlank()) {
Expand All @@ -64,7 +89,7 @@ public CaseComment addComment(String caseNumber, String content, String author)
}
Cases cases = findCase(caseNumber);
CaseComment comment = commentRepository.save(new CaseComment(cases, content, author));
eventRepository.save(new CaseEvent(cases, CaseEventType.COMMENT_ADDED,
saveAndBroadcast(new CaseEvent(cases, CaseEventType.COMMENT_ADDED,
author + " lade till en kommentar", author));
return comment;
}
Expand Down Expand Up @@ -93,10 +118,36 @@ public CaseStatus getStatus(String caseNumber) {
private void updateStatus(Cases cases, CaseStatus newStatus, String performedBy) {
CaseStatus previous = cases.getStatus();
cases.setStatus(newStatus);
eventRepository.save(new CaseEvent(cases, CaseEventType.STATUS_CHANGED,
saveAndBroadcast(new CaseEvent(cases, CaseEventType.STATUS_CHANGED,
"Status ändrades från " + previous + " till " + newStatus, performedBy));
}

private CaseEvent saveAndBroadcast(CaseEvent event) {
CaseEvent saved = eventRepository.save(event);
broadcast(event.getCaseEntity().getCaseNumber(), saved);
return saved;
}

private void broadcast(String caseNumber, CaseEvent event) {
CopyOnWriteArrayList<SseEmitter> list = emitters.get(caseNumber);
if (list == null || list.isEmpty()) return;
CaseEventResponse dto = CaseEventResponse.from(event);
List<SseEmitter> dead = new ArrayList<>();
for (SseEmitter emitter : list) {
try {
emitter.send(SseEmitter.event().name("case-event").data(dto));
} catch (IOException e) {
dead.add(emitter);
}
}
list.removeAll(dead);
}
Comment on lines +125 to +144

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Major: SSE events are broadcast before the transaction commits — risk of "ghost events" on rollback.

Every call site of saveAndBroadcast(...) (initCase, onHandlerAssigned, onHandlerRemoved, onDocumentUploaded, onRoleChanged, addComment, updateStatus) executes inside an active @Transactional method. eventRepository.save(...) only flushes; the row isn't durable until commit. The subsequent broadcast(...) then pushes the event to subscribers immediately. If anything later in the transaction fails (or UserService.updateAccount throws after onRoleChanged), the DB is rolled back but clients have already seen the event — their UI will show changes that never happened, and GET /events won't replay them.

A second consequence is that emitter.send(...) runs while the transaction is still open, so any slow subscriber prolongs lock/connection holding.

Recommended fix: publish a Spring application event from saveAndBroadcast and have a @TransactionalEventListener(phase = AFTER_COMMIT) perform the actual broadcast(...). This ensures clients only see committed events and removes I/O from the transactional path.

♻️ Sketch of the after-commit pattern
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.transaction.event.TransactionalEventListener;
+import org.springframework.transaction.event.TransactionPhase;
@@
-    public CaseLifecycleService(CasesRepository casesRepository,
+    private final ApplicationEventPublisher publisher;
+
+    public CaseLifecycleService(CasesRepository casesRepository,
                                 CaseEventRepository eventRepository,
-                                CaseCommentRepository commentRepository) {
+                                CaseCommentRepository commentRepository,
+                                ApplicationEventPublisher publisher) {
         this.casesRepository = casesRepository;
         this.eventRepository = eventRepository;
         this.commentRepository = commentRepository;
+        this.publisher = publisher;
     }
@@
     private CaseEvent saveAndBroadcast(CaseEvent event) {
         CaseEvent saved = eventRepository.save(event);
-        broadcast(event.getCaseEntity().getCaseNumber(), saved);
+        publisher.publishEvent(new CaseEventCommitted(saved.getCaseEntity().getCaseNumber(), saved));
         return saved;
     }
+
+    record CaseEventCommitted(String caseNumber, CaseEvent event) {}
+
+    `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
+    void onCommitted(CaseEventCommitted ev) {
+        broadcast(ev.caseNumber(), ev.event());
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/crimearchive/cases/CaseLifecycleService.java`
around lines 125 - 144, The saveAndBroadcast(CaseEvent) method currently saves
via eventRepository.save(...) and immediately calls broadcast(...), which sends
SSEs inside the transaction; change this to publish an application event (e.g.,
new CaseEventSaved or reuse CaseEvent) from saveAndBroadcast instead of calling
broadcast directly, and remove emitter I/O from that method so it only persists
the event; add a new `@Component` with a `@TransactionalEventListener`(phase =
TransactionalEventListener.Phase.AFTER_COMMIT) method that receives the
published event and calls the existing broadcast(String caseNumber, CaseEvent
event) to perform emitter.send(...) using emitters and
CaseEventResponse.from(...), ensuring broadcasts occur only after commit and
outside the transactional context.


private void removeEmitter(String caseNumber, SseEmitter emitter) {
CopyOnWriteArrayList<SseEmitter> list = emitters.get(caseNumber);
if (list != null) list.remove(emitter);
}

private Cases findCase(String caseNumber) {
return casesRepository.findFirstByCaseNumber(caseNumber)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
Expand Down
5 changes: 3 additions & 2 deletions src/main/java/org/example/crimearchive/cases/Cases.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package org.example.crimearchive.cases;

import jakarta.persistence.*;
import org.example.crimearchive.DTO.CreateReport;
import org.example.crimearchive.audit.Auditable;
import org.example.crimearchive.dto.CreateReport;
import org.example.crimearchive.polis.Account;
import org.example.crimearchive.reports.Report;

Expand All @@ -12,7 +13,7 @@
import java.util.UUID;

@Entity
public class Cases {
public class Cases extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/org/example/crimearchive/config/AuditConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.example.crimearchive.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class AuditConfig {

@Bean
public AuditorAware<String> auditorProvider() {
return () -> {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return Optional.of("SYSTEM");
}
return Optional.of(authentication.getName());
};
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ public ApplicationRunner createBucket(S3Client s3Client) {
}
};
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package org.example.crimearchive.controllers;

import jakarta.validation.Valid;
import org.example.crimearchive.DTO.Polis.DTOCreatePolis;
import org.example.crimearchive.DTO.Polis.DTOUpdatePolis;
import org.example.crimearchive.dto.police.DTOCreatePolis;
import org.example.crimearchive.dto.police.DTOUpdatePolis;
import org.example.crimearchive.exceptions.PasswordValidationException;
import org.example.crimearchive.polis.Account;
import org.example.crimearchive.polis.UserService;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package org.example.crimearchive.controllers;

import jakarta.validation.Valid;
import org.example.crimearchive.DTO.CreateReport;
import org.example.crimearchive.dto.CreateReport;
import org.example.crimearchive.cases.CaseService;
import org.example.crimearchive.cases.CasesRepository;
import org.example.crimearchive.evidence.EvidenceFileService;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package org.example.crimearchive.controllers;

import jakarta.validation.Valid;
import org.example.crimearchive.DTO.Polis.DTOUpdateProfile;
import org.example.crimearchive.dto.police.DTOUpdateProfile;
import org.example.crimearchive.cases.CaseLifecycleService;
import org.example.crimearchive.cases.CaseService;
import org.example.crimearchive.cases.CaseStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.example.crimearchive.DTO;
package org.example.crimearchive.dto;
Comment thread
Boppler12 marked this conversation as resolved.

import jakarta.validation.constraints.NotBlank;
import org.example.crimearchive.validations.ValidCasenumber;
Expand Down
Loading