From 73631f53a5acb00255764ef8d69a31ad639ecc8f Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Wed, 22 Apr 2026 17:48:24 +0200 Subject: [PATCH 01/47] =?UTF-8?q?added=20meetings=20(sammantr=C3=A4den)=20?= =?UTF-8?q?to=20admin-sidenav.html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/fragments/admin-sidenav.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/resources/templates/fragments/admin-sidenav.html b/src/main/resources/templates/fragments/admin-sidenav.html index 002e0da4..95898a2b 100644 --- a/src/main/resources/templates/fragments/admin-sidenav.html +++ b/src/main/resources/templates/fragments/admin-sidenav.html @@ -36,6 +36,13 @@ +
  • + + + Sammanträden + +
  • +
  • From c6850245044b144a8959f43735936eed87becec9 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 08:45:53 +0200 Subject: [PATCH 02/47] Added meeting entity-class and enum --- .../backendlab/team4you/meeting/Meeting.java | 159 ++++++++++++++++++ .../team4you/meeting/MeetingAgendaItem.java | 4 + .../team4you/meeting/MeetingStatus.java | 9 + 3 files changed, 172 insertions(+) create mode 100644 src/main/java/backendlab/team4you/meeting/Meeting.java create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingStatus.java diff --git a/src/main/java/backendlab/team4you/meeting/Meeting.java b/src/main/java/backendlab/team4you/meeting/Meeting.java new file mode 100644 index 00000000..22166faa --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/Meeting.java @@ -0,0 +1,159 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.registry.Registry; +import jakarta.persistence.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "meeting") +public class Meeting { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "registry_id", nullable = false) + private Registry registry; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "starts_at", nullable = false) + private LocalDateTime startsAt; + + @Column(name = "ends_at") + private LocalDateTime endsAt; + + @Column(length = 200) + private String location; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private MeetingStatus status; + + @Column(length = 2000) + private String notes; + + @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("agendaOrder ASC") + private List agendaItems = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected Meeting() { + } + + public Meeting( + Registry registry, + String title, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + MeetingStatus status, + String notes + ) { + this.registry = registry; + this.title = title; + this.startsAt = startsAt; + this.endsAt = endsAt; + this.location = location; + this.status = status; + this.notes = notes; + } + + @PrePersist + void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + + if (this.status == null) { + this.status = MeetingStatus.PLANNED; + } + } + + @PreUpdate + void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public Registry getRegistry() { + return registry; + } + + public void setRegistry(Registry registry) { + this.registry = registry; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDateTime getStartsAt() { + return startsAt; + } + + public void setStartsAt(LocalDateTime startsAt) { + this.startsAt = startsAt; + } + + public LocalDateTime getEndsAt() { + return endsAt; + } + + public void setEndsAt(LocalDateTime endsAt) { + this.endsAt = endsAt; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public MeetingStatus getStatus() { + return status; + } + + public void setStatus(MeetingStatus status) { + this.status = status; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public List getAgendaItems() { + return agendaItems; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java new file mode 100644 index 00000000..8c8471f7 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java @@ -0,0 +1,4 @@ +package backendlab.team4you.meeting; + +public class MeetingAgendaItem { +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingStatus.java b/src/main/java/backendlab/team4you/meeting/MeetingStatus.java new file mode 100644 index 00000000..9f503662 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingStatus.java @@ -0,0 +1,9 @@ +package backendlab.team4you.meeting; + +public enum MeetingStatus { + + PLANNED, + PREPARING, + COMPLETED, + CANCELLED +} From 5f4db845107aac72b9ea89b642186af7b791c315 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 08:49:34 +0200 Subject: [PATCH 03/47] Added MeetingAgendaItem entity --- .../meeting/MeetingAgendaDocument.java | 4 + .../team4you/meeting/MeetingAgendaItem.java | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java new file mode 100644 index 00000000..fe6e4eae --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java @@ -0,0 +1,4 @@ +package backendlab.team4you.meeting; + +public class MeetingAgendaDocument { +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java index 8c8471f7..80263afb 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java @@ -1,4 +1,94 @@ package backendlab.team4you.meeting; +import backendlab.team4you.caserecord.CaseRecord; +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table( + name = "meeting_agenda_item", + uniqueConstraints = { + @UniqueConstraint(name = "uk_meeting_agenda_item_meeting_case", columnNames = {"meeting_id", "case_record_id"}), + @UniqueConstraint(name = "uk_meeting_agenda_item_meeting_order", columnNames = {"meeting_id", "agenda_order"}) + } +) public class MeetingAgendaItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "meeting_id", nullable = false) + private Meeting meeting; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "case_record_id", nullable = false) + private CaseRecord caseRecord; + + @Column(name = "agenda_order", nullable = false) + private Integer agendaOrder; + + @Column(name = "agenda_note", length = 1000) + private String agendaNote; + + @OneToMany(mappedBy = "agendaItem", cascade = CascadeType.ALL, orphanRemoval = true) + private List documents = new ArrayList<>(); + + protected MeetingAgendaItem() { + } + + public MeetingAgendaItem( + Meeting meeting, + CaseRecord caseRecord, + Integer agendaOrder, + String agendaNote + ) { + this.meeting = meeting; + this.caseRecord = caseRecord; + this.agendaOrder = agendaOrder; + this.agendaNote = agendaNote; + } + + public Long getId() { + return id; + } + + public Meeting getMeeting() { + return meeting; + } + + public void setMeeting(Meeting meeting) { + this.meeting = meeting; + } + + public CaseRecord getCaseRecord() { + return caseRecord; + } + + public void setCaseRecord(CaseRecord caseRecord) { + this.caseRecord = caseRecord; + } + + public Integer getAgendaOrder() { + return agendaOrder; + } + + public void setAgendaOrder(Integer agendaOrder) { + this.agendaOrder = agendaOrder; + } + + public String getAgendaNote() { + return agendaNote; + } + + public void setAgendaNote(String agendaNote) { + this.agendaNote = agendaNote; + } + + public List getDocuments() { + return documents; + } } From acf23b6fd7939145b310e270f3b0f285acf31096 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 09:00:42 +0200 Subject: [PATCH 04/47] Added MeetingAgendaDocument entity --- .../meeting/MeetingAgendaDocument.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java index fe6e4eae..7fd50901 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java @@ -1,4 +1,54 @@ package backendlab.team4you.meeting; +import backendlab.team4you.casefile.CaseFile; +import jakarta.persistence.*; + +@Entity +@Table( + name = "meeting_agenda_document", + uniqueConstraints = { + @UniqueConstraint(name = "uk_meeting_agenda_document_item_file", columnNames = {"agenda_item_id", "case_file_id"}) + } +) public class MeetingAgendaDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "agenda_item_id", nullable = false) + private MeetingAgendaItem agendaItem; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "case_file_id", nullable = false) + private CaseFile caseFile; + + protected MeetingAgendaDocument() { + } + + public MeetingAgendaDocument(MeetingAgendaItem agendaItem, CaseFile caseFile) { + this.agendaItem = agendaItem; + this.caseFile = caseFile; + } + + public Long getId() { + return id; + } + + public MeetingAgendaItem getAgendaItem() { + return agendaItem; + } + + public void setAgendaItem(MeetingAgendaItem agendaItem) { + this.agendaItem = agendaItem; + } + + public CaseFile getCaseFile() { + return caseFile; + } + + public void setCaseFile(CaseFile caseFile) { + this.caseFile = caseFile; + } } From d38c9dc4e5ad84c1d83d70737d3e86289fdc220b Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 09:10:59 +0200 Subject: [PATCH 05/47] Added repositories --- .../backendlab/team4you/meeting/Meeting.java | 2 +- .../MeetingAgendaDocumentRepository.java | 16 ++++++++++++++++ .../meeting/MeetingAgendaItemRepository.java | 18 ++++++++++++++++++ .../team4you/meeting/MeetingRepository.java | 12 ++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingRepository.java diff --git a/src/main/java/backendlab/team4you/meeting/Meeting.java b/src/main/java/backendlab/team4you/meeting/Meeting.java index 22166faa..ebfbbf09 100644 --- a/src/main/java/backendlab/team4you/meeting/Meeting.java +++ b/src/main/java/backendlab/team4you/meeting/Meeting.java @@ -145,7 +145,7 @@ public void setNotes(String notes) { this.notes = notes; } - public List getAgendaItems() { + public List getAgendaItems() { return agendaItems; } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java new file mode 100644 index 00000000..afcd3c71 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java @@ -0,0 +1,16 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.casefile.CaseFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MeetingAgendaDocumentRepository extends JpaRepository { + + List findByAgendaItem(MeetingAgendaItem agendaItem); + + Optional findByAgendaItemAndCaseFile(MeetingAgendaItem agendaItem, CaseFile caseFile); + + boolean existsByAgendaItemAndCaseFile(MeetingAgendaItem agendaItem, CaseFile caseFile); +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java new file mode 100644 index 00000000..784eab61 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java @@ -0,0 +1,18 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.caserecord.CaseRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MeetingAgendaItemRepository extends JpaRepository { + + List findByMeetingOrderByAgendaOrderAsc(Meeting meeting); + + Optional findById(Meeting meeting, CaseRecord caseRecord); + + boolean existsByMeetingAndCaseRecord(Meeting meeting, CaseRecord caseRecord); + + long countByMeeting(Meeting meeting); +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java new file mode 100644 index 00000000..6ca94e2e --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java @@ -0,0 +1,12 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.registry.Registry; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MeetingRepository extends JpaRepository { + + List findByRegistryOrderByStartsAtAsc(Registry registry); + List findByRegistryOrderByStartsAtDesc(Registry registry); +} From 5f7dd8b528e5623d562b4c03a899d7e999ef6193 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 09:17:07 +0200 Subject: [PATCH 06/47] Added Flyway migration for meeting tables --- .../migration/V19__create_meeting_tables.sql | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/main/resources/db/migration/V19__create_meeting_tables.sql diff --git a/src/main/resources/db/migration/V19__create_meeting_tables.sql b/src/main/resources/db/migration/V19__create_meeting_tables.sql new file mode 100644 index 00000000..b65115af --- /dev/null +++ b/src/main/resources/db/migration/V19__create_meeting_tables.sql @@ -0,0 +1,50 @@ +CREATE TABLE meeting ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + registry_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + starts_at TIMESTAMP NOT NULL, + ends_at TIMESTAMP, + location VARCHAR(200), + status VARCHAR(30) NOT NULL, + notes VARCHAR(2000), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + CONSTRAINT fk_meeting_registry + FOREIGN KEY (registry_id) REFERENCES registry(id) +); + +CREATE TABLE meeting_agenda_item ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + meeting_id BIGINT NOT NULL, + case_record_id BIGINT NOT NULL, + agenda_order INTEGER NOT NULL, + agenda_note VARCHAR(1000), + + CONSTRAINT fk_meeting_agenda_item_meeting + FOREIGN KEY (meeting_id) REFERENCES meeting(id) ON DELETE CASCADE, + + CONSTRAINT fk_meeting_agenda_item_case_record + FOREIGN KEY (case_record_id) REFERENCES case_record(id), + + CONSTRAINT uk_meeting_agenda_item_meeting_case + UNIQUE (meeting_id, case_record_id), + + CONSTRAINT uk_meeting_agenda_item_meeting_order + UNIQUE (meeting_id, agenda_order) +); + +CREATE TABLE meeting_agenda_document ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + agenda_item_id BIGINT NOT NULL, + case_file_id BIGINT NOT NULL, + + CONSTRAINT fk_meeting_agenda_document_agenda_item + FOREIGN KEY (agenda_item_id) REFERENCES meeting_agenda_item(id) ON DELETE CASCADE, + + CONSTRAINT fk_meeting_agenda_document_case_file + FOREIGN KEY (case_file_id) REFERENCES case_file(id), + + CONSTRAINT uk_meeting_agenda_document_item_file + UNIQUE (agenda_item_id, case_file_id) +); \ No newline at end of file From aec7c8124d346c983f7d01a3b635627eedea1355 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 09:25:51 +0200 Subject: [PATCH 07/47] Added MeetingService --- .../team4you/meeting/MeetingService.java | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingService.java diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java new file mode 100644 index 00000000..51402d35 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -0,0 +1,246 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.casefile.CaseFileRepository; +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.registry.RegistryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@Transactional +public class MeetingService { + + private final MeetingRepository meetingRepository; + private final MeetingAgendaItemRepository meetingAgendaItemRepository; + private final MeetingAgendaDocumentRepository meetingAgendaDocumentRepository; + private final RegistryRepository registryRepository; + private final CaseRecordRepository caseRecordRepository; + private final CaseFileRepository caseFileRepository; + + public MeetingService( + MeetingRepository meetingRepository, + MeetingAgendaItemRepository meetingAgendaItemRepository, + MeetingAgendaDocumentRepository meetingAgendaDocumentRepository, + RegistryRepository registryRepository, + CaseRecordRepository caseRecordRepository, + CaseFileRepository caseFileRepository + ) { + this.meetingRepository = meetingRepository; + this.meetingAgendaItemRepository = meetingAgendaItemRepository; + this.meetingAgendaDocumentRepository = meetingAgendaDocumentRepository; + this.registryRepository = registryRepository; + this.caseRecordRepository = caseRecordRepository; + this.caseFileRepository = caseFileRepository; + } + + public Meeting createMeeting( + Long registryId, + String title, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + String notes + ) { + if (registryId == null) { + throw new IllegalArgumentException("Registry-id måste anges."); + } + + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("Titel måste anges."); + } + + if (startsAt == null) { + throw new IllegalArgumentException("Starttid måste anges."); + } + + if (endsAt != null && endsAt.isBefore(startsAt)) { + throw new IllegalArgumentException("Sluttid kan inte vara före starttid."); + } + + Registry registry = registryRepository.findById(registryId) + .orElseThrow(() -> new IllegalArgumentException("Registry hittades inte.")); + + Meeting meeting = new Meeting( + registry, + title.trim(), + startsAt, + endsAt, + blankToNull(location), + MeetingStatus.PLANNED, + blankToNull(notes) + ); + + return meetingRepository.save(meeting); + } + + @Transactional(readOnly = true) + public List getMeetingsForRegistry(Long registryId) { + Registry registry = registryRepository.findById(registryId) + .orElseThrow(() -> new IllegalArgumentException("Registry hittades inte.")); + + return meetingRepository.findByRegistryOrderByStartsAtDesc(registry); + } + + @Transactional(readOnly = true) + public Meeting getMeetingById(Long meetingId) { + return meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); + } + + @Transactional(readOnly = true) + public List getAgendaItems(Long meetingId) { + Meeting meeting = getMeetingById(meetingId); + return meetingAgendaItemRepository.findByMeetingOrderByAgendaOrderAsc(meeting); + } + + public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordId) { + if (meetingId == null) { + throw new IllegalArgumentException("Meeting-id måste anges."); + } + + if (caseRecordId == null) { + throw new IllegalArgumentException("Case record-id måste anges."); + } + + Meeting meeting = getMeetingById(meetingId); + + CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) + .orElseThrow(() -> new IllegalArgumentException("Ärendet hittades inte.")); + + validateCaseRecordBelongsToMeetingRegistry(meeting, caseRecord); + + if (meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)) { + throw new IllegalArgumentException("Ärendet är redan tillagt på sammanträdet."); + } + + long nextAgendaOrder = meetingAgendaItemRepository.countByMeeting(meeting) + 1; + + MeetingAgendaItem agendaItem = new MeetingAgendaItem( + meeting, + caseRecord, + (int) nextAgendaOrder, + null + ); + + return meetingAgendaItemRepository.save(agendaItem); + } + + public void removeAgendaItem(Long meetingId, Long agendaItemId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { + throw new IllegalArgumentException("Dagordningspunkten tillhör inte detta sammanträde."); + } + + meetingAgendaItemRepository.delete(agendaItem); + resequenceAgendaItems(meeting); + } + + @Transactional(readOnly = true) + public List getAgendaDocuments(Long agendaItemId) { + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + return meetingAgendaDocumentRepository.findByAgendaItem(agendaItem); + } + + public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agendaItemId, Long caseFileId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { + throw new IllegalArgumentException("Dagordningspunkten tillhör inte detta sammanträde."); + } + + CaseFile caseFile = caseFileRepository.findById(caseFileId) + .orElseThrow(() -> new IllegalArgumentException("Handlingen hittades inte.")); + + validateCaseFileBelongsToAgendaItemCaseRecord(agendaItem, caseFile); + + if (meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)) { + throw new IllegalArgumentException("Handlingen är redan vald för denna dagordningspunkt."); + } + + MeetingAgendaDocument document = new MeetingAgendaDocument(agendaItem, caseFile); + return meetingAgendaDocumentRepository.save(document); + } + + public void removeDocumentFromAgendaItem(Long meetingId, Long agendaItemId, Long documentId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { + throw new IllegalArgumentException("Dagordningspunkten tillhör inte detta sammanträde."); + } + + MeetingAgendaDocument document = meetingAgendaDocumentRepository.findById(documentId) + .orElseThrow(() -> new IllegalArgumentException("Dokumentkopplingen hittades inte.")); + + if (!document.getAgendaItem().getId().equals(agendaItem.getId())) { + throw new IllegalArgumentException("Dokumentet tillhör inte denna dagordningspunkt."); + } + + meetingAgendaDocumentRepository.delete(document); + } + + public Meeting updateMeetingStatus(Long meetingId, MeetingStatus status) { + if (status == null) { + throw new IllegalArgumentException("Status måste anges."); + } + + Meeting meeting = getMeetingById(meetingId); + meeting.setStatus(status); + + return meetingRepository.save(meeting); + } + + private void validateCaseRecordBelongsToMeetingRegistry(Meeting meeting, CaseRecord caseRecord) { + if (caseRecord.getRegistry() == null || caseRecord.getRegistry().getId() == null) { + throw new IllegalArgumentException("Ärendet saknar diarum."); + } + + if (!caseRecord.getRegistry().getId().equals(meeting.getRegistry().getId())) { + throw new IllegalArgumentException("Ärendet tillhör inte samma organisation som sammanträdet."); + } + } + + private void validateCaseFileBelongsToAgendaItemCaseRecord(MeetingAgendaItem agendaItem, CaseFile caseFile) { + if (caseFile.getCaseRecord() == null || caseFile.getCaseRecord().getId() == null) { + throw new IllegalArgumentException("Handlingen saknar kopplat ärende."); + } + + if (!caseFile.getCaseRecord().getId().equals(agendaItem.getCaseRecord().getId())) { + throw new IllegalArgumentException("Handlingen tillhör inte ärendet på denna dagordningspunkt."); + } + } + + private void resequenceAgendaItems(Meeting meeting) { + List agendaItems = meetingAgendaItemRepository.findByMeetingOrderByAgendaOrderAsc(meeting); + + int order = 1; + for (MeetingAgendaItem agendaItem : agendaItems) { + agendaItem.setAgendaOrder(order); + order++; + } + } + + private String blankToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } +} From ab3e73196ba5c109131ca74290ceae70fcfd1427 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 09:35:36 +0200 Subject: [PATCH 08/47] Added MeetingController --- .../team4you/meeting/MeetingController.java | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/main/java/backendlab/team4you/meeting/MeetingController.java diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java new file mode 100644 index 00000000..01f8356a --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -0,0 +1,161 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.registry.RegistryRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Controller +@RequestMapping("/admin/meetings") +public class MeetingController { + + private final MeetingService meetingService; + private final RegistryRepository registryRepository; + private final CaseRecordRepository caseRecordRepository; + + public MeetingController( + MeetingService meetingService, + RegistryRepository registryRepository, + CaseRecordRepository caseRecordRepository + ) { + this.meetingService = meetingService; + this.registryRepository = registryRepository; + this.caseRecordRepository = caseRecordRepository; + } + + @GetMapping + public String meetingsPage( + @RequestParam(required = false) Long registryId, + @RequestParam(required = false) Long selectedMeetingId, + Model model + ) { + List registries = registryRepository.findAll(); + model.addAttribute("registries", registries); + model.addAttribute("selectedRegistryId", registryId); + + List meetings = registryId != null + ? meetingService.getMeetingsForRegistry(registryId) + : List.of(); + + model.addAttribute("meetings", meetings); + + Meeting selectedMeeting = null; + List agendaItems = List.of(); + List availableCaseRecords = List.of(); + + if (selectedMeetingId != null) { + selectedMeeting = meetingService.getMeetingById(selectedMeetingId); + agendaItems = meetingService.getAgendaItems(selectedMeetingId); + + Long selectedRegistryId = selectedMeeting.getRegistry().getId(); + availableCaseRecords = caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(selectedRegistryId); + model.addAttribute("selectedRegistryId", selectedRegistryId); + } + + model.addAttribute("selectedMeeting", selectedMeeting); + model.addAttribute("agendaItems", agendaItems); + model.addAttribute("availableCaseRecords", availableCaseRecords); + + return "fragments/admin-meetings :: content"; + } + + @PostMapping + public String createMeeting( + @RequestParam Long registryId, + @RequestParam String title, + @RequestParam String startsAt, + @RequestParam(required = false) String endsAt, + @RequestParam(required = false) String location, + @RequestParam(required = false) String notes, + Model model + ) { + try { + LocalDateTime parsedStartsAt = LocalDateTime.parse(startsAt); + LocalDateTime parsedEndsAt = (endsAt == null || endsAt.isBlank()) + ? null + : LocalDateTime.parse(endsAt); + + Meeting meeting = meetingService.createMeeting( + registryId, + title, + parsedStartsAt, + parsedEndsAt, + location, + notes + ); + + model.addAttribute("successMessage", "Sammanträdet skapades."); + return meetingsPage(registryId, meeting.getId(), model); + + } catch (IllegalArgumentException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + return meetingsPage(registryId, null, model); + } + } + + @GetMapping("/{meetingId}") + public String showMeeting( + @PathVariable Long meetingId, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + + List registries = registryRepository.findAll(); + List meetings = meetingService.getMeetingsForRegistry(meeting.getRegistry().getId()); + List agendaItems = meetingService.getAgendaItems(meetingId); + List availableCaseRecords = + caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(meeting.getRegistry().getId()); + + model.addAttribute("registries", registries); + model.addAttribute("selectedRegistryId", meeting.getRegistry().getId()); + model.addAttribute("meetings", meetings); + model.addAttribute("selectedMeeting", meeting); + model.addAttribute("agendaItems", agendaItems); + model.addAttribute("availableCaseRecords", availableCaseRecords); + + return "fragments/admin-meetings :: content"; + } + + @PostMapping("/{meetingId}/agenda-items") + public String addAgendaItem( + @PathVariable Long meetingId, + @RequestParam Long caseRecordId, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + + try { + meetingService.addCaseRecordToMeeting(meetingId, caseRecordId); + model.addAttribute("successMessage", "Ärendet lades till på sammanträdet."); + } catch (IllegalArgumentException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + return showMeeting(meetingId, model); + } + + @DeleteMapping("/{meetingId}/agenda-items/{agendaItemId}") + public String removeAgendaItem( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + Model model + ) { + try { + meetingService.removeAgendaItem(meetingId, agendaItemId); + model.addAttribute("successMessage", "Dagordningspunkten togs bort."); + } catch (IllegalArgumentException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + return showMeeting(meetingId, model); + } +} From 6a57259663d443ff31acb29d4cc4dd3e2927f539 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 09:42:25 +0200 Subject: [PATCH 09/47] Added fragment admin-meetings.html --- .../templates/fragments/admin-meetings.html | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/main/resources/templates/fragments/admin-meetings.html diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html new file mode 100644 index 00000000..cdf76610 --- /dev/null +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -0,0 +1,178 @@ +
    + +

    Sammanträden

    + + + + +
    + +
    +

    Skapa sammanträde

    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    + +
    + +

    Befintliga sammanträden

    + +
    +

    Inga sammanträden att visa.

    +
    + +
    +
    + +
    +
    +

    Välj ett sammanträde

    +

    Skapa ett nytt sammanträde eller välj ett befintligt i listan.

    +
    + +
    +

    Sammanträde

    + +

    + Registry: + Kommunstyrelsen +

    + +

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

    + +

    + Slut: + 2026-05-10 15:00 +

    + +

    + Plats: + Sessionssalen +

    + +

    + Status: + PLANNED +

    + +

    + Anteckningar: + Anteckning +

    + +
    + +

    Lägg till ärende

    + +
    +
    + + +
    + + +
    + +
    + +

    Dagordning

    + +
    +

    Inga ärenden tillagda ännu.

    +
    + +
      +
    1. +
      +
      + KS26-1 + Ärendetitel +
      + +
      + +
      +
      +
    2. +
    +
    +
    + +
    +
    \ No newline at end of file From 44ba9b0a6ab11b3ae49686a717aa6e683614394d Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 11:09:45 +0200 Subject: [PATCH 10/47] Added ui functionality --- .../team4you/casefile/CaseFileRepository.java | 1 + .../team4you/meeting/MeetingController.java | 116 ++++++----- src/main/resources/static/css/admin.css | 2 +- src/main/resources/static/css/meetings.css | 188 ++++++++++++++++++ .../resources/templates/admin-layout.html | 14 +- .../templates/fragments/admin-meetings.html | 3 + .../templates/fragments/admin-sidenav.html | 7 +- 7 files changed, 273 insertions(+), 58 deletions(-) create mode 100644 src/main/resources/static/css/meetings.css diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java index 0691f527..2ff8cbfe 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java @@ -12,4 +12,5 @@ public interface CaseFileRepository extends JpaRepository { Optional findByIdAndCaseRecordId(Long id, Long caseRecordId); @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findTopByCaseRecordIdOrderByDocumentNumberDesc(Long caseRecordId); + List findByCaseRecordIdOrderByCreatedAtDesc(Long caseRecordId); } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 01f8356a..6ed70b02 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -7,13 +7,10 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; import java.util.List; -import static org.springframework.http.HttpStatus.NOT_FOUND; - @Controller @RequestMapping("/admin/meetings") public class MeetingController { @@ -38,33 +35,7 @@ public String meetingsPage( @RequestParam(required = false) Long selectedMeetingId, Model model ) { - List registries = registryRepository.findAll(); - model.addAttribute("registries", registries); - model.addAttribute("selectedRegistryId", registryId); - - List meetings = registryId != null - ? meetingService.getMeetingsForRegistry(registryId) - : List.of(); - - model.addAttribute("meetings", meetings); - - Meeting selectedMeeting = null; - List agendaItems = List.of(); - List availableCaseRecords = List.of(); - - if (selectedMeetingId != null) { - selectedMeeting = meetingService.getMeetingById(selectedMeetingId); - agendaItems = meetingService.getAgendaItems(selectedMeetingId); - - Long selectedRegistryId = selectedMeeting.getRegistry().getId(); - availableCaseRecords = caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(selectedRegistryId); - model.addAttribute("selectedRegistryId", selectedRegistryId); - } - - model.addAttribute("selectedMeeting", selectedMeeting); - model.addAttribute("agendaItems", agendaItems); - model.addAttribute("availableCaseRecords", availableCaseRecords); - + populateMeetingsPage(model, registryId, selectedMeetingId); return "fragments/admin-meetings :: content"; } @@ -76,6 +47,7 @@ public String createMeeting( @RequestParam(required = false) String endsAt, @RequestParam(required = false) String location, @RequestParam(required = false) String notes, + @RequestHeader(value = "HX-Request", required = false) String htmx, Model model ) { try { @@ -93,69 +65,113 @@ public String createMeeting( notes ); - model.addAttribute("successMessage", "Sammanträdet skapades."); - return meetingsPage(registryId, meeting.getId(), model); + model.addAttribute("successMessage", "sammanträdet skapades."); + populateMeetingsPage(model, registryId, meeting.getId()); } catch (IllegalArgumentException exception) { model.addAttribute("errorMessage", exception.getMessage()); - return meetingsPage(registryId, null, model); + populateMeetingsPage(model, registryId, null); } + + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } + + return "admin/meetings"; } @GetMapping("/{meetingId}") public String showMeeting( @PathVariable Long meetingId, + @RequestHeader(value = "HX-Request", required = false) String htmx, Model model ) { Meeting meeting = meetingService.getMeetingById(meetingId); + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); - List registries = registryRepository.findAll(); - List meetings = meetingService.getMeetingsForRegistry(meeting.getRegistry().getId()); - List agendaItems = meetingService.getAgendaItems(meetingId); - List availableCaseRecords = - caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(meeting.getRegistry().getId()); - - model.addAttribute("registries", registries); - model.addAttribute("selectedRegistryId", meeting.getRegistry().getId()); - model.addAttribute("meetings", meetings); - model.addAttribute("selectedMeeting", meeting); - model.addAttribute("agendaItems", agendaItems); - model.addAttribute("availableCaseRecords", availableCaseRecords); + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } - return "fragments/admin-meetings :: content"; + return "admin/meetings"; } @PostMapping("/{meetingId}/agenda-items") public String addAgendaItem( @PathVariable Long meetingId, @RequestParam Long caseRecordId, + @RequestHeader(value = "HX-Request", required = false) String htmx, Model model ) { Meeting meeting = meetingService.getMeetingById(meetingId); try { meetingService.addCaseRecordToMeeting(meetingId, caseRecordId); - model.addAttribute("successMessage", "Ärendet lades till på sammanträdet."); + model.addAttribute("successMessage", "ärendet lades till på sammanträdet."); } catch (IllegalArgumentException exception) { model.addAttribute("errorMessage", exception.getMessage()); } - return showMeeting(meetingId, model); + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } + + return "admin/meetings"; } @DeleteMapping("/{meetingId}/agenda-items/{agendaItemId}") public String removeAgendaItem( @PathVariable Long meetingId, @PathVariable Long agendaItemId, + @RequestHeader(value = "HX-Request", required = false) String htmx, Model model ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + try { meetingService.removeAgendaItem(meetingId, agendaItemId); - model.addAttribute("successMessage", "Dagordningspunkten togs bort."); + model.addAttribute("successMessage", "dagordningspunkten togs bort."); } catch (IllegalArgumentException exception) { model.addAttribute("errorMessage", exception.getMessage()); } - return showMeeting(meetingId, model); + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } + + return "admin/meetings"; + } + + private void populateMeetingsPage(Model model, Long registryId, Long selectedMeetingId) { + List registries = registryRepository.findAll(); + model.addAttribute("registries", registries); + model.addAttribute("selectedRegistryId", registryId); + + List meetings = registryId != null + ? meetingService.getMeetingsForRegistry(registryId) + : List.of(); + + model.addAttribute("meetings", meetings); + + Meeting selectedMeeting = null; + List agendaItems = List.of(); + List availableCaseRecords = List.of(); + + if (selectedMeetingId != null) { + selectedMeeting = meetingService.getMeetingById(selectedMeetingId); + agendaItems = meetingService.getAgendaItems(selectedMeetingId); + + Long selectedRegistryId = selectedMeeting.getRegistry().getId(); + availableCaseRecords = caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(selectedRegistryId); + model.addAttribute("selectedRegistryId", selectedRegistryId); + } + + model.addAttribute("selectedMeeting", selectedMeeting); + model.addAttribute("agendaItems", agendaItems); + model.addAttribute("availableCaseRecords", availableCaseRecords); } } diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index abee6cbe..a9c1653b 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -84,4 +84,4 @@ li{ .pagination button.active { background: #6017a8; color: white; -} \ No newline at end of file +} diff --git a/src/main/resources/static/css/meetings.css b/src/main/resources/static/css/meetings.css new file mode 100644 index 00000000..538650bb --- /dev/null +++ b/src/main/resources/static/css/meetings.css @@ -0,0 +1,188 @@ +.admin-meetings-page { + padding: 24px; +} + +.admin-meetings-page h2 { + margin-bottom: 20px; + font-size: 2rem; +} + +.admin-meetings-layout { + display: grid; + grid-template-columns: 360px 1fr; + gap: 24px; + align-items: start; +} + +.meeting-sidebar, +.meeting-detail { + background: #ffffff; + border: 1px solid #d9e2ec; + border-radius: 18px; + padding: 20px; + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.06); +} + +.meeting-sidebar h3, +.meeting-detail h3, +.meeting-detail h4 { + margin-top: 0; + margin-bottom: 16px; + color: #101828; +} + +.meeting-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.meeting-form .form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.meeting-form label { + font-weight: 600; + color: #344054; +} + +.meeting-form input, +.meeting-form select, +.meeting-form textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid #d0d5dd; + border-radius: 10px; + font: inherit; + background: #ffffff; + box-sizing: border-box; +} + +.meeting-form input:focus, +.meeting-form select:focus, +.meeting-form textarea:focus { + outline: none; + border-color: #6f1ab6; + box-shadow: 0 0 0 4px rgba(111, 26, 182, 0.12); +} + +.meeting-form .submit { + margin-top: 8px; + width: 100%; + border: none; + border-radius: 14px; + padding: 12px 16px; + font-weight: 700; + font-size: 1rem; + color: white; + background: linear-gradient(135deg, #6f1ab6, #5b21b6); + cursor: pointer; +} + +.meeting-form .submit:hover { + filter: brightness(1.05); +} + +.meeting-list { + list-style: none; + padding: 0; + margin: 0; +} + +.meeting-list li + li { + margin-top: 10px; +} + +.meeting-list-link { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border: 1px solid #e4e7ec; + border-radius: 12px; + text-decoration: none; + color: inherit; + background: #f8f5ff; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.meeting-list-link:hover { + background: #f1ebff; + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(111, 26, 182, 0.10); +} + +.meeting-list-link strong { + color: #1d2939; +} + +.meeting-list-link span { + color: #667085; + font-size: 0.95rem; +} + +.meeting-agenda-list { + padding-left: 20px; + margin-top: 12px; +} + +.agenda-item-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 14px 16px; + margin-bottom: 12px; + border: 1px solid #e4e7ec; + border-radius: 14px; + background: #fcfcfd; +} + +.agenda-item-main { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agenda-item-main strong { + color: #101828; +} + +.agenda-item-main span { + color: #475467; +} + +.danger-button { + background: #b42318; + color: white; + border: none; + border-radius: 10px; + padding: 10px 14px; + font-weight: 600; + cursor: pointer; +} + +.danger-button:hover { + background: #912018; +} + +.feedback-message { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 12px; + font-weight: 600; +} + +.success-message { + background: #ecfdf3; + color: #027a48; + border: 1px solid #abefc6; +} + +.error-message { + background: #fef3f2; + color: #b42318; + border: 1px solid #fecdca; +} \ No newline at end of file diff --git a/src/main/resources/templates/admin-layout.html b/src/main/resources/templates/admin-layout.html index 21f6a95d..0d4e1b3c 100644 --- a/src/main/resources/templates/admin-layout.html +++ b/src/main/resources/templates/admin-layout.html @@ -9,15 +9,19 @@ + - - - - - +
    + + +
    +
    +
    +
    + diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index cdf76610..180b85fe 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -16,6 +16,7 @@

    Skapa sammanträde

    hx-swap="innerHTML" class="meeting-form" > +
    diff --git a/src/main/resources/templates/fragments/admin-sidenav.html b/src/main/resources/templates/fragments/admin-sidenav.html index 95898a2b..194e4703 100644 --- a/src/main/resources/templates/fragments/admin-sidenav.html +++ b/src/main/resources/templates/fragments/admin-sidenav.html @@ -37,8 +37,11 @@
  • - - + + Sammanträden
  • From e112b02c3ee3e9e2ce6e849d26d18ae657631374 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 11:14:40 +0200 Subject: [PATCH 11/47] Added agenda documents support to MeetingService and MeetingController --- .../team4you/meeting/MeetingController.java | 40 +++++++++++++++++++ .../team4you/meeting/MeetingService.java | 8 ++++ 2 files changed, 48 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 6ed70b02..22295381 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -146,6 +146,46 @@ public String removeAgendaItem( return "admin/meetings"; } + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/documents") + public String addAgendaDocument( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + @RequestParam Long caseFileId, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + + try { + meetingService.addDocumentToAgendaItem(meetingId, agendaItemId, caseFileId); + model.addAttribute("successMessage", "Handlingen lades till som beslutsunderlag."); + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + return "fragments/admin-meetings :: content"; + } + + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/documents/{documentId}/remove") + public String removeAgendaDocument( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + @PathVariable Long documentId, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + + try { + meetingService.removeDocumentFromAgendaItem(meetingId, agendaItemId, documentId); + model.addAttribute("successMessage", "Handlingen togs bort från beslutsunderlaget."); + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + return "fragments/admin-meetings :: content"; + } + private void populateMeetingsPage(Model model, Long registryId, Long selectedMeetingId) { List registries = registryRepository.findAll(); model.addAttribute("registries", registries); diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 51402d35..57b5775a 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -153,6 +153,14 @@ public List getAgendaDocuments(Long agendaItemId) { return meetingAgendaDocumentRepository.findByAgendaItem(agendaItem); } + @Transactional(readOnly = true) + public List getAvailableCaseFilesForAgendaItem(Long agendaItemId) { + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + return caseFileRepository.findByCaseRecordIdOrderByCreatedAtDesc(agendaItem.getCaseRecord().getId()); + } + public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agendaItemId, Long caseFileId) { Meeting meeting = getMeetingById(meetingId); From 874bc4c20098888c68daeb28e208f360e46e43c8 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 11:16:04 +0200 Subject: [PATCH 12/47] Reworked populateMeetingsPage to support agenda documents. --- .../team4you/meeting/MeetingController.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 22295381..1162fc79 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -1,5 +1,6 @@ package backendlab.team4you.meeting; +import backendlab.team4you.casefile.CaseFile; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; import backendlab.team4you.registry.Registry; @@ -9,7 +10,9 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Controller @RequestMapping("/admin/meetings") @@ -201,6 +204,9 @@ private void populateMeetingsPage(Model model, Long registryId, Long selectedMee List agendaItems = List.of(); List availableCaseRecords = List.of(); + Map> documentsByAgendaItemId = new HashMap<>(); + Map> availableFilesByAgendaItemId = new HashMap<>(); + if (selectedMeetingId != null) { selectedMeeting = meetingService.getMeetingById(selectedMeetingId); agendaItems = meetingService.getAgendaItems(selectedMeetingId); @@ -208,10 +214,24 @@ private void populateMeetingsPage(Model model, Long registryId, Long selectedMee Long selectedRegistryId = selectedMeeting.getRegistry().getId(); availableCaseRecords = caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(selectedRegistryId); model.addAttribute("selectedRegistryId", selectedRegistryId); + + for (MeetingAgendaItem agendaItem : agendaItems) { + documentsByAgendaItemId.put( + agendaItem.getId(), + meetingService.getAgendaDocuments(agendaItem.getId()) + ); + + availableFilesByAgendaItemId.put( + agendaItem.getId(), + meetingService.getAvailableCaseFilesForAgendaItem(agendaItem.getId()) + ); + } } model.addAttribute("selectedMeeting", selectedMeeting); model.addAttribute("agendaItems", agendaItems); model.addAttribute("availableCaseRecords", availableCaseRecords); + model.addAttribute("documentsByAgendaItemId", documentsByAgendaItemId); + model.addAttribute("availableFilesByAgendaItemId", availableFilesByAgendaItemId); } } From 4a8f3b85d34f4da50615d01af83c04bc533366be Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 11:38:10 +0200 Subject: [PATCH 13/47] Added agenda document support to ui --- .../team4you/casefile/CaseFileRepository.java | 2 +- .../team4you/meeting/MeetingService.java | 2 +- .../templates/fragments/admin-meetings.html | 52 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java index 2ff8cbfe..eb309c10 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java @@ -12,5 +12,5 @@ public interface CaseFileRepository extends JpaRepository { Optional findByIdAndCaseRecordId(Long id, Long caseRecordId); @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findTopByCaseRecordIdOrderByDocumentNumberDesc(Long caseRecordId); - List findByCaseRecordIdOrderByCreatedAtDesc(Long caseRecordId); + List findByCaseRecordIdOrderByUploadedAtDesc(Long caseRecordId); } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 57b5775a..8fa929a0 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -158,7 +158,7 @@ public List getAvailableCaseFilesForAgendaItem(Long agendaItemId) { MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); - return caseFileRepository.findByCaseRecordIdOrderByCreatedAtDesc(agendaItem.getCaseRecord().getId()); + return caseFileRepository.findByCaseRecordIdOrderByUploadedAtDesc(agendaItem.getCaseRecord().getId()); } public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agendaItemId, Long caseFileId) { diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 180b85fe..936418b2 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -169,9 +169,59 @@

    Dagordning

    hx-swap="innerHTML" > - + + +
    +
    Valda beslutsunderlag
    + +
    +

    Inga handlingar valda ännu.

    +
    + +
      +
    • +
      + tjansteskrivelse.pdf + +
      + + +
      +
      +
    • +
    + +
    Tillgängliga handlingar
    + +
    +

    Det finns inga handlingar kopplade till ärendet.

    +
    + +
      +
    • +
      + budgetunderlag.pdf + +
      + + + + +
      +
      +
    • +
    +
    From eed6f805284d008583d7fca92a8ec08a6c0c5999 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 11:50:21 +0200 Subject: [PATCH 14/47] Current meetings now lists correctly --- .../java/backendlab/team4you/meeting/MeetingController.java | 2 +- .../java/backendlab/team4you/meeting/MeetingRepository.java | 1 + .../java/backendlab/team4you/meeting/MeetingService.java | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 1162fc79..a60f3779 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -196,7 +196,7 @@ private void populateMeetingsPage(Model model, Long registryId, Long selectedMee List meetings = registryId != null ? meetingService.getMeetingsForRegistry(registryId) - : List.of(); + : meetingService.getAllMeetings(); model.addAttribute("meetings", meetings); diff --git a/src/main/java/backendlab/team4you/meeting/MeetingRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java index 6ca94e2e..debbfd31 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingRepository.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java @@ -9,4 +9,5 @@ public interface MeetingRepository extends JpaRepository { List findByRegistryOrderByStartsAtAsc(Registry registry); List findByRegistryOrderByStartsAtDesc(Registry registry); + List findAllByOrderByStartsAtDesc(); } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 8fa929a0..df0333bc 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -93,6 +93,11 @@ public Meeting getMeetingById(Long meetingId) { .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); } + @Transactional(readOnly = true) + public List getAllMeetings() { + return meetingRepository.findAllByOrderByStartsAtDesc(); + } + @Transactional(readOnly = true) public List getAgendaItems(Long meetingId) { Meeting meeting = getMeetingById(meetingId); From 2fad9c5d364b3b8f507b0e6c9e1e45d02799d810 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 17:12:21 +0200 Subject: [PATCH 15/47] Added updateMeeting and deleteMeeting to MeetingService --- .../team4you/meeting/MeetingService.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index df0333bc..53736cea 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -79,6 +79,61 @@ public Meeting createMeeting( return meetingRepository.save(meeting); } + @Transactional + public Meeting updateMeeting( + Long meetingId, + String title, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + String notes, + MeetingStatus status + ) { + if (meetingId == null) { + throw new IllegalArgumentException("Meeting-id måste anges."); + } + + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("Titel måste anges."); + } + + if (startsAt == null) { + throw new IllegalArgumentException("Starttid måste anges."); + } + + if (endsAt != null && endsAt.isBefore(startsAt)) { + throw new IllegalArgumentException("Sluttid kan inte vara före starttid."); + } + + if (status == null) { + throw new IllegalArgumentException("Status måste anges."); + } + + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); + + meeting.setTitle(title.trim()); + meeting.setStartsAt(startsAt); + meeting.setEndsAt(endsAt); + meeting.setLocation(blankToNull(location)); + meeting.setNotes(blankToNull(notes)); + meeting.setStatus(status); + + return meetingRepository.save(meeting); + } + + @Transactional + public void deleteMeeting(Long meetingId) { + if (meetingId == null) { + throw new IllegalArgumentException("Meeting-id måste anges."); + } + + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); + + meetingRepository.delete(meeting); + } + @Transactional(readOnly = true) public List getMeetingsForRegistry(Long registryId) { Registry registry = registryRepository.findById(registryId) From 9d3ef5bf79729e0ef11c181b34cd903f766b1cb3 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 17:13:54 +0200 Subject: [PATCH 16/47] Added updateMeeting and deleteMeeting to to MeetingController --- .../team4you/meeting/MeetingController.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index a60f3779..7a548be2 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -83,6 +83,67 @@ public String createMeeting( return "admin/meetings"; } + @PostMapping("/{meetingId}/update") + public String updateMeeting( + @PathVariable Long meetingId, + @RequestParam String title, + @RequestParam String startsAt, + @RequestParam(required = false) String endsAt, + @RequestParam(required = false) String location, + @RequestParam(required = false) String notes, + @RequestParam MeetingStatus status, + Model model + ) { + try { + LocalDateTime parsedStartsAt = LocalDateTime.parse(startsAt); + LocalDateTime parsedEndsAt = (endsAt == null || endsAt.isBlank()) + ? null + : LocalDateTime.parse(endsAt); + + Meeting updatedMeeting = meetingService.updateMeeting( + meetingId, + title, + parsedStartsAt, + parsedEndsAt, + location, + notes, + status + ); + + model.addAttribute("successMessage", "sammanträdet uppdaterades."); + populateMeetingsPage(model, updatedMeeting.getRegistry().getId(), updatedMeeting.getId()); + + } catch (Exception exception) { + Meeting meeting = meetingService.getMeetingById(meetingId); + model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + } + + return "fragments/admin-meetings :: content"; + } + + @PostMapping("/{meetingId}/delete") + public String deleteMeeting( + @PathVariable Long meetingId, + Model model + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + Long registryId = meeting.getRegistry().getId(); + + meetingService.deleteMeeting(meetingId); + + model.addAttribute("successMessage", "sammanträdet togs bort."); + populateMeetingsPage(model, registryId, null); + + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPage(model, null, null); + } + + return "fragments/admin-meetings :: content"; + } + @GetMapping("/{meetingId}") public String showMeeting( @PathVariable Long meetingId, From 9896c3168208d44abf1194adb2bd495921f37ab3 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 17:27:07 +0200 Subject: [PATCH 17/47] Added meeting editing to ui --- .../templates/fragments/admin-meetings.html | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 936418b2..9939f2e5 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -123,6 +123,64 @@

    Sammanträde


    +

    Redigera sammanträde

    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    + +
    +

    Lägg till ärende

    Date: Thu, 23 Apr 2026 17:32:10 +0200 Subject: [PATCH 18/47] Added delete meeting to ui --- src/main/resources/static/css/meetings.css | 4 ++++ .../resources/templates/fragments/admin-meetings.html | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/resources/static/css/meetings.css b/src/main/resources/static/css/meetings.css index 538650bb..11e623fa 100644 --- a/src/main/resources/static/css/meetings.css +++ b/src/main/resources/static/css/meetings.css @@ -185,4 +185,8 @@ background: #fef3f2; color: #b42318; border: 1px solid #fecdca; +} + +.delete-meeting-form { + margin-top: 12px; } \ No newline at end of file diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 9939f2e5..8d1a6332 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -179,6 +179,16 @@

    Redigera sammanträde

    +
    + + +
    +

    Lägg till ärende

    From c52ff73431464abfbb6aee2dc16cbdc92390389d Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 17:40:41 +0200 Subject: [PATCH 19/47] Added move agenda item to MeetingService and Repo --- .../meeting/MeetingAgendaItemRepository.java | 4 ++ .../team4you/meeting/MeetingService.java | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java index 784eab61..61310f2f 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java @@ -15,4 +15,8 @@ public interface MeetingAgendaItemRepository extends JpaRepository findByMeetingAndAgendaOrder(Meeting meeting, Integer agendaOrder); + + Optional findByIdAndMeeting(Long id, Meeting meeting); } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 53736cea..67d64e51 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -191,6 +191,52 @@ public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordI return meetingAgendaItemRepository.save(agendaItem); } + @Transactional + public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + if (currentItem.getAgendaOrder() == null || currentItem.getAgendaOrder() <= 1) { + return; + } + + int currentOrder = currentItem.getAgendaOrder(); + int targetOrder = currentOrder - 1; + + MeetingAgendaItem previousItem = meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, targetOrder) + .orElseThrow(() -> new IllegalArgumentException("Kunde inte flytta upp dagordningspunkten.")); + + currentItem.setAgendaOrder(targetOrder); + previousItem.setAgendaOrder(currentOrder); + } + + @Transactional + public void moveAgendaItemDown(Long meetingId, Long agendaItemId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) + .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + + if (currentItem.getAgendaOrder() == null) { + return; + } + + int currentOrder = currentItem.getAgendaOrder(); + int targetOrder = currentOrder + 1; + + MeetingAgendaItem nextItem = meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, targetOrder) + .orElse(null); + + if (nextItem == null) { + return; + } + + currentItem.setAgendaOrder(targetOrder); + nextItem.setAgendaOrder(currentOrder); + } + public void removeAgendaItem(Long meetingId, Long agendaItemId) { Meeting meeting = getMeetingById(meetingId); From 5268461c43ccbe9edab5b5dba579236bff45d177 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 17:42:51 +0200 Subject: [PATCH 20/47] Added move agenda item to MeetingController --- .../team4you/meeting/MeetingController.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 7a548be2..f83ed853 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -185,6 +185,44 @@ public String addAgendaItem( return "admin/meetings"; } + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/move-up") + public String moveAgendaItemUp( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + + try { + meetingService.moveAgendaItemUp(meetingId, agendaItemId); + model.addAttribute("successMessage", "Dagordningspunkten flyttades upp."); + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + return "fragments/admin-meetings :: content"; + } + + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/move-down") + public String moveAgendaItemDown( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + + try { + meetingService.moveAgendaItemDown(meetingId, agendaItemId); + model.addAttribute("successMessage", "Dagordningspunkten flyttades ner."); + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + return "fragments/admin-meetings :: content"; + } + @DeleteMapping("/{meetingId}/agenda-items/{agendaItemId}") public String removeAgendaItem( @PathVariable Long meetingId, From f76168242d7fe54fb54b28b8a8a1b37fdbe53a1a Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 18:06:56 +0200 Subject: [PATCH 21/47] Added move agenda item to ui and saveandflush-fix to MeetingService --- .../team4you/meeting/MeetingService.java | 16 +++++++- src/main/resources/static/css/meetings.css | 21 ++++++++++ .../templates/fragments/admin-meetings.html | 38 ++++++++++++++----- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 67d64e51..f0806f81 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -208,8 +208,14 @@ public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { MeetingAgendaItem previousItem = meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, targetOrder) .orElseThrow(() -> new IllegalArgumentException("Kunde inte flytta upp dagordningspunkten.")); - currentItem.setAgendaOrder(targetOrder); + currentItem.setAgendaOrder(0); + meetingAgendaItemRepository.saveAndFlush(currentItem); + previousItem.setAgendaOrder(currentOrder); + meetingAgendaItemRepository.saveAndFlush(previousItem); + + currentItem.setAgendaOrder(targetOrder); + meetingAgendaItemRepository.saveAndFlush(currentItem); } @Transactional @@ -233,8 +239,14 @@ public void moveAgendaItemDown(Long meetingId, Long agendaItemId) { return; } - currentItem.setAgendaOrder(targetOrder); + currentItem.setAgendaOrder(0); + meetingAgendaItemRepository.saveAndFlush(currentItem); + nextItem.setAgendaOrder(currentOrder); + meetingAgendaItemRepository.saveAndFlush(nextItem); + + currentItem.setAgendaOrder(targetOrder); + meetingAgendaItemRepository.saveAndFlush(currentItem); } public void removeAgendaItem(Long meetingId, Long agendaItemId) { diff --git a/src/main/resources/static/css/meetings.css b/src/main/resources/static/css/meetings.css index 11e623fa..3b4bdbaf 100644 --- a/src/main/resources/static/css/meetings.css +++ b/src/main/resources/static/css/meetings.css @@ -189,4 +189,25 @@ .delete-meeting-form { margin-top: 12px; +} + +.agenda-item-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.secondary-button { + background: #eef2ff; + color: #3730a3; + border: 1px solid #c7d2fe; + border-radius: 10px; + padding: 10px 14px; + font-weight: 600; + cursor: pointer; +} + +.secondary-button:hover { + background: #e0e7ff; } \ No newline at end of file diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 8d1a6332..67355565 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -227,18 +227,38 @@

    Dagordning

  • - KS26-1 + 1. KS26-1 Ärendetitel
    -
    - - -
    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    From 49eeb8d19dfcc64593343d88e323d54a19523a8e Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 18:27:34 +0200 Subject: [PATCH 22/47] Added new exceptions --- .../DuplicateMeetingAgendaDocumentException.java | 7 +++++++ .../exceptions/DuplicateMeetingAgendaItemException.java | 7 +++++++ .../team4you/exceptions/GlobalViewExceptionHandler.java | 2 +- .../team4you/exceptions/InvalidMeetingStateException.java | 7 +++++++ .../exceptions/MeetingAgendaDocumentNotFoundException.java | 7 +++++++ .../exceptions/MeetingAgendaItemNotFoundException.java | 7 +++++++ .../team4you/exceptions/MeetingNotFoundException.java | 7 +++++++ .../java/backendlab/team4you/meeting/MeetingService.java | 3 ++- 8 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaDocumentException.java create mode 100644 src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaItemException.java create mode 100644 src/main/java/backendlab/team4you/exceptions/InvalidMeetingStateException.java create mode 100644 src/main/java/backendlab/team4you/exceptions/MeetingAgendaDocumentNotFoundException.java create mode 100644 src/main/java/backendlab/team4you/exceptions/MeetingAgendaItemNotFoundException.java create mode 100644 src/main/java/backendlab/team4you/exceptions/MeetingNotFoundException.java diff --git a/src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaDocumentException.java b/src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaDocumentException.java new file mode 100644 index 00000000..ede955e3 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaDocumentException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class DuplicateMeetingAgendaDocumentException extends RuntimeException { + public DuplicateMeetingAgendaDocumentException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaItemException.java b/src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaItemException.java new file mode 100644 index 00000000..78c9e0fc --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/DuplicateMeetingAgendaItemException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class DuplicateMeetingAgendaItemException extends RuntimeException { + public DuplicateMeetingAgendaItemException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java index c59466a4..2ae47814 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; -@ControllerAdvice(basePackages = "backendlab.team4you.casefile.ui") +@ControllerAdvice(basePackages = { "backendlab.team4you.casefile.ui","backendlab.team4you.meeting"}) public class GlobalViewExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalViewExceptionHandler.class); diff --git a/src/main/java/backendlab/team4you/exceptions/InvalidMeetingStateException.java b/src/main/java/backendlab/team4you/exceptions/InvalidMeetingStateException.java new file mode 100644 index 00000000..480a72a5 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/InvalidMeetingStateException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class InvalidMeetingStateException extends RuntimeException { + public InvalidMeetingStateException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/MeetingAgendaDocumentNotFoundException.java b/src/main/java/backendlab/team4you/exceptions/MeetingAgendaDocumentNotFoundException.java new file mode 100644 index 00000000..f52196b5 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/MeetingAgendaDocumentNotFoundException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class MeetingAgendaDocumentNotFoundException extends RuntimeException { + public MeetingAgendaDocumentNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/MeetingAgendaItemNotFoundException.java b/src/main/java/backendlab/team4you/exceptions/MeetingAgendaItemNotFoundException.java new file mode 100644 index 00000000..62d0c926 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/MeetingAgendaItemNotFoundException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class MeetingAgendaItemNotFoundException extends RuntimeException { + public MeetingAgendaItemNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/MeetingNotFoundException.java b/src/main/java/backendlab/team4you/exceptions/MeetingNotFoundException.java new file mode 100644 index 00000000..85687712 --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/MeetingNotFoundException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class MeetingNotFoundException extends RuntimeException { + public MeetingNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index f0806f81..97b4f164 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -4,6 +4,7 @@ import backendlab.team4you.casefile.CaseFileRepository; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.exceptions.MeetingNotFoundException; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.springframework.stereotype.Service; @@ -145,7 +146,7 @@ public List getMeetingsForRegistry(Long registryId) { @Transactional(readOnly = true) public Meeting getMeetingById(Long meetingId) { return meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); + .orElseThrow(() -> new MeetingNotFoundException("Sammanträdet hittades inte.")); } @Transactional(readOnly = true) From 649f5995a4e09dbc1d27c0d2c97cf4f9603e3a8d Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 19:13:51 +0200 Subject: [PATCH 23/47] Changed to use new exceptions --- .../GlobalRestExceptionHandler.java | 12 +++- .../team4you/meeting/MeetingService.java | 70 +++++++++---------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java index 6e3530ec..47246d4a 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java @@ -43,7 +43,10 @@ public ResponseEntity handleAccessDenied(AccessDeniedException CaseFileNotFoundException.class, CaseRecordNotFoundException.class, RegistryNotFoundException.class, - UserNotFoundException.class + UserNotFoundException.class, + MeetingNotFoundException.class, + MeetingAgendaItemNotFoundException.class, + MeetingAgendaDocumentNotFoundException.class }) public ResponseEntity handleNotFound(RuntimeException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) @@ -58,7 +61,8 @@ public ResponseEntity handleNotFound(RuntimeException ex) { @ExceptionHandler({ InvalidFileNameException.class, IllegalArgumentException.class, - IllegalStateException.class + IllegalStateException.class, + InvalidMeetingStateException.class }) public ResponseEntity handleBadRequest(RuntimeException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -85,7 +89,9 @@ public ResponseEntity handleFileTooLarge(FileTooLargeException DuplicateRegistryNameException.class, DuplicateRegistryCodeException.class, DuplicateEmailException.class, - FileKeyConflictException.class + FileKeyConflictException.class, + DuplicateMeetingAgendaItemException.class, + DuplicateMeetingAgendaDocumentException.class }) public ResponseEntity handleConflict(RuntimeException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 97b4f164..8cee3957 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -4,7 +4,7 @@ import backendlab.team4you.casefile.CaseFileRepository; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; -import backendlab.team4you.exceptions.MeetingNotFoundException; +import backendlab.team4you.exceptions.*; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.springframework.stereotype.Service; @@ -49,23 +49,23 @@ public Meeting createMeeting( String notes ) { if (registryId == null) { - throw new IllegalArgumentException("Registry-id måste anges."); + throw new InvalidMeetingStateException("Registry-id måste anges."); } if (title == null || title.isBlank()) { - throw new IllegalArgumentException("Titel måste anges."); + throw new InvalidMeetingStateException("Titel måste anges."); } if (startsAt == null) { - throw new IllegalArgumentException("Starttid måste anges."); + throw new InvalidMeetingStateException("Starttid måste anges."); } if (endsAt != null && endsAt.isBefore(startsAt)) { - throw new IllegalArgumentException("Sluttid kan inte vara före starttid."); + throw new InvalidMeetingStateException("Sluttid kan inte vara före starttid."); } Registry registry = registryRepository.findById(registryId) - .orElseThrow(() -> new IllegalArgumentException("Registry hittades inte.")); + .orElseThrow(() -> new RegistryNotFoundException("Registry hittades inte.")); Meeting meeting = new Meeting( registry, @@ -91,27 +91,27 @@ public Meeting updateMeeting( MeetingStatus status ) { if (meetingId == null) { - throw new IllegalArgumentException("Meeting-id måste anges."); + throw new InvalidMeetingStateException("Meeting-id måste anges."); } if (title == null || title.isBlank()) { - throw new IllegalArgumentException("Titel måste anges."); + throw new InvalidMeetingStateException("Titel måste anges."); } if (startsAt == null) { - throw new IllegalArgumentException("Starttid måste anges."); + throw new InvalidMeetingStateException("Starttid måste anges."); } if (endsAt != null && endsAt.isBefore(startsAt)) { - throw new IllegalArgumentException("Sluttid kan inte vara före starttid."); + throw new InvalidMeetingStateException("Sluttid kan inte vara före starttid."); } if (status == null) { - throw new IllegalArgumentException("Status måste anges."); + throw new InvalidMeetingStateException("Status måste anges."); } Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); + .orElseThrow(() -> new MeetingNotFoundException("Sammanträdet hittades inte.")); meeting.setTitle(title.trim()); meeting.setStartsAt(startsAt); @@ -126,11 +126,11 @@ public Meeting updateMeeting( @Transactional public void deleteMeeting(Long meetingId) { if (meetingId == null) { - throw new IllegalArgumentException("Meeting-id måste anges."); + throw new InvalidMeetingStateException("Meeting-id måste anges."); } Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("Sammanträdet hittades inte.")); + .orElseThrow(() -> new MeetingNotFoundException("Sammanträdet hittades inte.")); meetingRepository.delete(meeting); } @@ -162,11 +162,11 @@ public List getAgendaItems(Long meetingId) { public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordId) { if (meetingId == null) { - throw new IllegalArgumentException("Meeting-id måste anges."); + throw new InvalidMeetingStateException("Meeting-id måste anges."); } if (caseRecordId == null) { - throw new IllegalArgumentException("Case record-id måste anges."); + throw new InvalidMeetingStateException("Case record-id måste anges."); } Meeting meeting = getMeetingById(meetingId); @@ -177,7 +177,7 @@ public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordI validateCaseRecordBelongsToMeetingRegistry(meeting, caseRecord); if (meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)) { - throw new IllegalArgumentException("Ärendet är redan tillagt på sammanträdet."); + throw new DuplicateMeetingAgendaItemException("Ärendet är redan tillagt på sammanträdet."); } long nextAgendaOrder = meetingAgendaItemRepository.countByMeeting(meeting) + 1; @@ -197,7 +197,7 @@ public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { Meeting meeting = getMeetingById(meetingId); MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); if (currentItem.getAgendaOrder() == null || currentItem.getAgendaOrder() <= 1) { return; @@ -207,7 +207,7 @@ public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { int targetOrder = currentOrder - 1; MeetingAgendaItem previousItem = meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, targetOrder) - .orElseThrow(() -> new IllegalArgumentException("Kunde inte flytta upp dagordningspunkten.")); + .orElseThrow(() -> new InvalidMeetingStateException("Kunde inte flytta upp dagordningspunkten.")); currentItem.setAgendaOrder(0); meetingAgendaItemRepository.saveAndFlush(currentItem); @@ -224,7 +224,7 @@ public void moveAgendaItemDown(Long meetingId, Long agendaItemId) { Meeting meeting = getMeetingById(meetingId); MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); if (currentItem.getAgendaOrder() == null) { return; @@ -254,10 +254,10 @@ public void removeAgendaItem(Long meetingId, Long agendaItemId) { Meeting meeting = getMeetingById(meetingId); MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { - throw new IllegalArgumentException("Dagordningspunkten tillhör inte detta sammanträde."); + throw new InvalidMeetingStateException("Dagordningspunkten tillhör inte detta sammanträde."); } meetingAgendaItemRepository.delete(agendaItem); @@ -267,7 +267,7 @@ public void removeAgendaItem(Long meetingId, Long agendaItemId) { @Transactional(readOnly = true) public List getAgendaDocuments(Long agendaItemId) { MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); return meetingAgendaDocumentRepository.findByAgendaItem(agendaItem); } @@ -275,7 +275,7 @@ public List getAgendaDocuments(Long agendaItemId) { @Transactional(readOnly = true) public List getAvailableCaseFilesForAgendaItem(Long agendaItemId) { MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); return caseFileRepository.findByCaseRecordIdOrderByUploadedAtDesc(agendaItem.getCaseRecord().getId()); } @@ -284,19 +284,19 @@ public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agenda Meeting meeting = getMeetingById(meetingId); MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { - throw new IllegalArgumentException("Dagordningspunkten tillhör inte detta sammanträde."); + throw new InvalidMeetingStateException("Dagordningspunkten tillhör inte detta sammanträde."); } CaseFile caseFile = caseFileRepository.findById(caseFileId) - .orElseThrow(() -> new IllegalArgumentException("Handlingen hittades inte.")); + .orElseThrow(() -> new MeetingAgendaDocumentNotFoundException("Handlingen hittades inte.")); validateCaseFileBelongsToAgendaItemCaseRecord(agendaItem, caseFile); if (meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)) { - throw new IllegalArgumentException("Handlingen är redan vald för denna dagordningspunkt."); + throw new DuplicateMeetingAgendaDocumentException("Handlingen är redan vald för denna dagordningspunkt."); } MeetingAgendaDocument document = new MeetingAgendaDocument(agendaItem, caseFile); @@ -307,17 +307,17 @@ public void removeDocumentFromAgendaItem(Long meetingId, Long agendaItemId, Long Meeting meeting = getMeetingById(meetingId); MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) - .orElseThrow(() -> new IllegalArgumentException("Dagordningspunkten hittades inte.")); + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { - throw new IllegalArgumentException("Dagordningspunkten tillhör inte detta sammanträde."); + throw new InvalidMeetingStateException("Dagordningspunkten tillhör inte detta sammanträde."); } MeetingAgendaDocument document = meetingAgendaDocumentRepository.findById(documentId) .orElseThrow(() -> new IllegalArgumentException("Dokumentkopplingen hittades inte.")); if (!document.getAgendaItem().getId().equals(agendaItem.getId())) { - throw new IllegalArgumentException("Dokumentet tillhör inte denna dagordningspunkt."); + throw new InvalidMeetingStateException("Dokumentet tillhör inte denna dagordningspunkt."); } meetingAgendaDocumentRepository.delete(document); @@ -336,21 +336,21 @@ public Meeting updateMeetingStatus(Long meetingId, MeetingStatus status) { private void validateCaseRecordBelongsToMeetingRegistry(Meeting meeting, CaseRecord caseRecord) { if (caseRecord.getRegistry() == null || caseRecord.getRegistry().getId() == null) { - throw new IllegalArgumentException("Ärendet saknar diarum."); + throw new InvalidMeetingStateException("Ärendet saknar diarum."); } if (!caseRecord.getRegistry().getId().equals(meeting.getRegistry().getId())) { - throw new IllegalArgumentException("Ärendet tillhör inte samma organisation som sammanträdet."); + throw new InvalidMeetingStateException("Ärendet tillhör inte samma organisation som sammanträdet."); } } private void validateCaseFileBelongsToAgendaItemCaseRecord(MeetingAgendaItem agendaItem, CaseFile caseFile) { if (caseFile.getCaseRecord() == null || caseFile.getCaseRecord().getId() == null) { - throw new IllegalArgumentException("Handlingen saknar kopplat ärende."); + throw new InvalidMeetingStateException("Handlingen saknar kopplat ärende."); } if (!caseFile.getCaseRecord().getId().equals(agendaItem.getCaseRecord().getId())) { - throw new IllegalArgumentException("Handlingen tillhör inte ärendet på denna dagordningspunkt."); + throw new InvalidMeetingStateException("Handlingen tillhör inte ärendet på denna dagordningspunkt."); } } From 4a96588492251a18d6be2c98b22ea706ba61c25a Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 19:43:24 +0200 Subject: [PATCH 24/47] Added MeetingServiceTest --- .../team4you/meeting/MeetingServiceTest.java | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java diff --git a/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java new file mode 100644 index 00000000..4d1d6831 --- /dev/null +++ b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java @@ -0,0 +1,364 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.casefile.CaseFileRepository; +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.exceptions.DuplicateMeetingAgendaDocumentException; +import backendlab.team4you.exceptions.DuplicateMeetingAgendaItemException; +import backendlab.team4you.exceptions.InvalidMeetingStateException; +import backendlab.team4you.exceptions.MeetingNotFoundException; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.registry.RegistryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MeetingServiceTest { + + @Mock + private MeetingRepository meetingRepository; + + @Mock + private MeetingAgendaItemRepository meetingAgendaItemRepository; + + @Mock + private MeetingAgendaDocumentRepository meetingAgendaDocumentRepository; + + @Mock + private RegistryRepository registryRepository; + + @Mock + private CaseRecordRepository caseRecordRepository; + + @Mock + private CaseFileRepository caseFileRepository; + + @InjectMocks + private MeetingService meetingService; + + private Registry registry; + private Registry otherRegistry; + private Meeting meeting; + + private CaseRecord caseRecord; + private CaseRecord otherRegistryCaseRecord; + + private CaseFile caseFile; + private CaseFile wrongCaseFile; + + @BeforeEach + void setUp() { + registry = new Registry("Kommunstyrelsen", "KS"); + setField(registry, "id", 1L); + + otherRegistry = new Registry("Byggnadsnämnden", "BN"); + setField(otherRegistry, "id", 2L); + + meeting = new Meeting( + registry, + "KS april", + LocalDateTime.of(2026, 4, 30, 13, 0), + LocalDateTime.of(2026, 4, 30, 15, 0), + "Sessionssalen", + MeetingStatus.PLANNED, + "Anteckning" + ); + setField(meeting, "id", 10L); + + caseRecord = mock(CaseRecord.class); + otherRegistryCaseRecord = mock(CaseRecord.class); + caseFile = mock(CaseFile.class); + wrongCaseFile = mock(CaseFile.class); + } + + @Test + @DisplayName("createMeeting should save meeting when input is valid") + void createMeeting_shouldSaveMeeting_whenInputIsValid() { + LocalDateTime startsAt = LocalDateTime.of(2026, 5, 10, 13, 0); + LocalDateTime endsAt = LocalDateTime.of(2026, 5, 10, 15, 0); + + when(registryRepository.findById(1L)).thenReturn(Optional.of(registry)); + when(meetingRepository.save(any(Meeting.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Meeting result = meetingService.createMeeting( + 1L, + "Kommunstyrelsen maj", + startsAt, + endsAt, + "Sessionssalen", + "Viktig anteckning" + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Meeting.class); + verify(meetingRepository).save(captor.capture()); + + Meeting savedMeeting = captor.getValue(); + assertThat(savedMeeting.getRegistry()).isEqualTo(registry); + assertThat(savedMeeting.getTitle()).isEqualTo("Kommunstyrelsen maj"); + assertThat(savedMeeting.getStartsAt()).isEqualTo(startsAt); + assertThat(savedMeeting.getEndsAt()).isEqualTo(endsAt); + assertThat(savedMeeting.getLocation()).isEqualTo("Sessionssalen"); + assertThat(savedMeeting.getNotes()).isEqualTo("Viktig anteckning"); + + assertThat(result.getTitle()).isEqualTo("Kommunstyrelsen maj"); + } + + @Test + @DisplayName("createMeeting should throw InvalidMeetingStateException when title is blank") + void createMeeting_shouldThrowInvalidMeetingStateException_whenTitleIsBlank() { + assertThatThrownBy(() -> meetingService.createMeeting( + 1L, + " ", + LocalDateTime.of(2026, 5, 10, 13, 0), + null, + null, + null + )) + .isInstanceOf(InvalidMeetingStateException.class) + .hasMessage("Titel måste anges."); + } + + @Test + @DisplayName("createMeeting should throw InvalidMeetingStateException when end is before start") + void createMeeting_shouldThrowInvalidMeetingStateException_whenEndIsBeforeStart() { + assertThatThrownBy(() -> meetingService.createMeeting( + 1L, + "Testmöte", + LocalDateTime.of(2026, 5, 10, 15, 0), + LocalDateTime.of(2026, 5, 10, 13, 0), + null, + null + )) + .isInstanceOf(InvalidMeetingStateException.class) + .hasMessage("Sluttid kan inte vara före starttid."); + } + + @Test + @DisplayName("getMeetingById should throw MeetingNotFoundException when meeting does not exist") + void getMeetingById_shouldThrowMeetingNotFoundException_whenMeetingDoesNotExist() { + when(meetingRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> meetingService.getMeetingById(999L)) + .isInstanceOf(MeetingNotFoundException.class) + .hasMessage("Sammanträdet hittades inte."); + } + + @Test + @DisplayName("updateMeeting should update existing meeting") + void updateMeeting_shouldUpdateExistingMeeting() { + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingRepository.save(any(Meeting.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Meeting updated = meetingService.updateMeeting( + 10L, + "Nytt mötesnamn", + LocalDateTime.of(2026, 5, 1, 9, 0), + LocalDateTime.of(2026, 5, 1, 11, 0), + "Nya salen", + "Nya anteckningar", + MeetingStatus.PREPARING + ); + + assertThat(updated.getTitle()).isEqualTo("Nytt mötesnamn"); + assertThat(updated.getStartsAt()).isEqualTo(LocalDateTime.of(2026, 5, 1, 9, 0)); + assertThat(updated.getEndsAt()).isEqualTo(LocalDateTime.of(2026, 5, 1, 11, 0)); + assertThat(updated.getLocation()).isEqualTo("Nya salen"); + assertThat(updated.getNotes()).isEqualTo("Nya anteckningar"); + assertThat(updated.getStatus()).isEqualTo(MeetingStatus.PREPARING); + } + + @Test + @DisplayName("addCaseRecordToMeeting should create agenda item with next order") + void addCaseRecordToMeeting_shouldCreateAgendaItemWithNextOrder() { + when(caseRecord.getRegistry()).thenReturn(registry); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(caseRecordRepository.findById(100L)).thenReturn(Optional.of(caseRecord)); + when(meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)).thenReturn(false); + when(meetingAgendaItemRepository.countByMeeting(meeting)).thenReturn(2L); + when(meetingAgendaItemRepository.save(any(MeetingAgendaItem.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + MeetingAgendaItem result = meetingService.addCaseRecordToMeeting(10L, 100L); + + assertThat(result.getMeeting()).isEqualTo(meeting); + assertThat(result.getCaseRecord()).isEqualTo(caseRecord); + assertThat(result.getAgendaOrder()).isEqualTo(3); + } + + @Test + @DisplayName("addCaseRecordToMeeting should throw DuplicateMeetingAgendaItemException when case record already exists") + void addCaseRecordToMeeting_shouldThrowDuplicateMeetingAgendaItemException_whenCaseRecordAlreadyExists() { + when(caseRecord.getRegistry()).thenReturn(registry); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(caseRecordRepository.findById(100L)).thenReturn(Optional.of(caseRecord)); + when(meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)).thenReturn(true); + + assertThatThrownBy(() -> meetingService.addCaseRecordToMeeting(10L, 100L)) + .isInstanceOf(DuplicateMeetingAgendaItemException.class) + .hasMessage("Ärendet är redan tillagt på sammanträdet."); + } + + @Test + @DisplayName("addCaseRecordToMeeting should throw InvalidMeetingStateException when case record belongs to different registry") + void addCaseRecordToMeeting_shouldThrowInvalidMeetingStateException_whenCaseRecordBelongsToDifferentRegistry() { + when(otherRegistryCaseRecord.getRegistry()).thenReturn(otherRegistry); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(caseRecordRepository.findById(200L)).thenReturn(Optional.of(otherRegistryCaseRecord)); + + assertThatThrownBy(() -> meetingService.addCaseRecordToMeeting(10L, 200L)) + .isInstanceOf(InvalidMeetingStateException.class) + .hasMessage("Ärendet tillhör inte samma organisation som sammanträdet."); + } + + @Test + @DisplayName("addDocumentToAgendaItem should add document when file belongs to same case record") + void addDocumentToAgendaItem_shouldAddDocument_whenFileBelongsToSameCaseRecord() { + MeetingAgendaItem agendaItem = new MeetingAgendaItem(meeting, caseRecord, 1, null); + setField(agendaItem, "id", 50L); + + when(caseFile.getCaseRecord()).thenReturn(caseRecord); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); + when(caseFileRepository.findById(1000L)).thenReturn(Optional.of(caseFile)); + when(meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)).thenReturn(false); + when(meetingAgendaDocumentRepository.save(any(MeetingAgendaDocument.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + MeetingAgendaDocument result = meetingService.addDocumentToAgendaItem(10L, 50L, 1000L); + + assertThat(result.getAgendaItem()).isEqualTo(agendaItem); + assertThat(result.getCaseFile()).isEqualTo(caseFile); + } + + @Test + @DisplayName("addDocumentToAgendaItem should throw DuplicateMeetingAgendaDocumentException when document already exists") + void addDocumentToAgendaItem_shouldThrowDuplicateMeetingAgendaDocumentException_whenDocumentAlreadyExists() { + MeetingAgendaItem agendaItem = new MeetingAgendaItem(meeting, caseRecord, 1, null); + setField(agendaItem, "id", 50L); + + when(caseFile.getCaseRecord()).thenReturn(caseRecord); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); + when(caseFileRepository.findById(1000L)).thenReturn(Optional.of(caseFile)); + when(meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)).thenReturn(true); + + assertThatThrownBy(() -> meetingService.addDocumentToAgendaItem(10L, 50L, 1000L)) + .isInstanceOf(DuplicateMeetingAgendaDocumentException.class) + .hasMessage("Handlingen är redan vald för denna dagordningspunkt."); + } + + @Test + @DisplayName("addDocumentToAgendaItem should throw InvalidMeetingStateException when file belongs to wrong case record") + void addDocumentToAgendaItem_shouldThrowInvalidMeetingStateException_whenFileBelongsToWrongCaseRecord() { + MeetingAgendaItem agendaItem = new MeetingAgendaItem(meeting, caseRecord, 1, null); + setField(agendaItem, "id", 50L); + + when(caseRecord.getId()).thenReturn(100L); + when(otherRegistryCaseRecord.getId()).thenReturn(200L); + when(wrongCaseFile.getCaseRecord()).thenReturn(otherRegistryCaseRecord); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); + when(caseFileRepository.findById(2000L)).thenReturn(Optional.of(wrongCaseFile)); + + assertThatThrownBy(() -> meetingService.addDocumentToAgendaItem(10L, 50L, 2000L)) + .isInstanceOf(InvalidMeetingStateException.class) + .hasMessage("Handlingen tillhör inte ärendet på denna dagordningspunkt."); + } + + @Test + @DisplayName("removeAgendaItem should delete item and resequence remaining items") + void removeAgendaItem_shouldDeleteItemAndResequenceRemainingItems() { + MeetingAgendaItem item1 = new MeetingAgendaItem(meeting, caseRecord, 1, null); + setField(item1, "id", 11L); + + MeetingAgendaItem item2 = new MeetingAgendaItem(meeting, caseRecord, 2, null); + setField(item2, "id", 12L); + + MeetingAgendaItem item3 = new MeetingAgendaItem(meeting, caseRecord, 3, null); + setField(item3, "id", 13L); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(12L)).thenReturn(Optional.of(item2)); + when(meetingAgendaItemRepository.findByMeetingOrderByAgendaOrderAsc(meeting)) + .thenReturn(List.of(item1, item3)); + + meetingService.removeAgendaItem(10L, 12L); + + verify(meetingAgendaItemRepository).delete(item2); + assertThat(item1.getAgendaOrder()).isEqualTo(1); + assertThat(item3.getAgendaOrder()).isEqualTo(2); + } + + @Test + @DisplayName("moveAgendaItemUp should swap agenda order with previous item") + void moveAgendaItemUp_shouldSwapAgendaOrderWithPreviousItem() { + MeetingAgendaItem item1 = new MeetingAgendaItem(meeting, caseRecord, 1, null); + setField(item1, "id", 11L); + + MeetingAgendaItem item2 = new MeetingAgendaItem(meeting, caseRecord, 2, null); + setField(item2, "id", 12L); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findByIdAndMeeting(12L, meeting)).thenReturn(Optional.of(item2)); + when(meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, 1)).thenReturn(Optional.of(item1)); + + meetingService.moveAgendaItemUp(10L, 12L); + + assertThat(item2.getAgendaOrder()).isEqualTo(1); + assertThat(item1.getAgendaOrder()).isEqualTo(2); + verify(meetingAgendaItemRepository, atLeastOnce()).saveAndFlush(any(MeetingAgendaItem.class)); + } + + @Test + @DisplayName("moveAgendaItemDown should swap agenda order with next item") + void moveAgendaItemDown_shouldSwapAgendaOrderWithNextItem() { + MeetingAgendaItem item2 = new MeetingAgendaItem(meeting, caseRecord, 2, null); + setField(item2, "id", 12L); + + MeetingAgendaItem item3 = new MeetingAgendaItem(meeting, caseRecord, 3, null); + setField(item3, "id", 13L); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findByIdAndMeeting(12L, meeting)).thenReturn(Optional.of(item2)); + when(meetingAgendaItemRepository.findByMeetingAndAgendaOrder(meeting, 3)).thenReturn(Optional.of(item3)); + + meetingService.moveAgendaItemDown(10L, 12L); + + assertThat(item2.getAgendaOrder()).isEqualTo(3); + assertThat(item3.getAgendaOrder()).isEqualTo(2); + verify(meetingAgendaItemRepository, atLeastOnce()).saveAndFlush(any(MeetingAgendaItem.class)); + } + + private void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} From e2fc82f496474972d5c6f42df9afabaa1992ee8f Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Thu, 23 Apr 2026 20:19:47 +0200 Subject: [PATCH 25/47] Added MeetingControllerTest --- .../team4you/meeting/MeetingController.java | 5 +- .../templates/fragments/admin-meetings.html | 45 ++- .../meeting/MeetingControllerTest.java | 266 ++++++++++++++++++ 3 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index f83ed853..1e5be483 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -3,6 +3,9 @@ import backendlab.team4you.casefile.CaseFile; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.exceptions.InvalidMeetingStateException; +import backendlab.team4you.exceptions.MeetingNotFoundException; +import backendlab.team4you.exceptions.RegistryNotFoundException; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.springframework.stereotype.Controller; @@ -71,7 +74,7 @@ public String createMeeting( model.addAttribute("successMessage", "sammanträdet skapades."); populateMeetingsPage(model, registryId, meeting.getId()); - } catch (IllegalArgumentException exception) { + } catch (InvalidMeetingStateException | MeetingNotFoundException | RegistryNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); populateMeetingsPage(model, registryId, null); } diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 67355565..6c381cd7 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -16,7 +16,10 @@

    Skapa sammanträde

    hx-swap="innerHTML" class="meeting-form" > - +
    +
    @@ -185,7 +191,10 @@

    Redigera sammanträde

    hx-swap="innerHTML" class="delete-meeting-form" > - + @@ -199,7 +208,10 @@

    Lägg till ärende

    hx-swap="innerHTML" class="meeting-form" > - +
    + @@ -246,7 +261,10 @@

    Dagordning

    hx-target="#content-area" hx-swap="innerHTML" > - + @@ -255,7 +273,10 @@

    Dagordning

    hx-target="#content-area" hx-swap="innerHTML" > - +
    @@ -278,7 +299,10 @@
    Valda beslutsunderlag
    hx-target="#content-area" hx-swap="innerHTML" > - +
    @@ -301,7 +325,10 @@
    Tillgängliga handlingar
    hx-target="#content-area" hx-swap="innerHTML" > - + diff --git a/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java new file mode 100644 index 00000000..905ab88e --- /dev/null +++ b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java @@ -0,0 +1,266 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.exceptions.InvalidMeetingStateException; +import backendlab.team4you.registry.Registry; +import backendlab.team4you.registry.RegistryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(MeetingController.class) +class MeetingControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private MeetingService meetingService; + + @MockitoBean + private RegistryRepository registryRepository; + + @MockitoBean + private CaseRecordRepository caseRecordRepository; + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("GET /admin/meetings should return meetings fragment") + void meetingsPage_shouldReturnMeetingsFragment() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.getAllMeetings()).thenReturn(List.of()); + + mockMvc.perform(get("/admin/meetings") + .header("HX-Request", "true")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attributeExists("registries")) + .andExpect(model().attributeExists("meetings")) + .andExpect(model().attribute("selectedMeeting", nullValue())) + .andExpect(model().attributeExists("agendaItems")) + .andExpect(model().attributeExists("availableCaseRecords")); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("POST /admin/meetings should create meeting and return success message") + void createMeeting_shouldReturnFragmentAndSuccessMessage_whenRequestIsValid() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + Meeting createdMeeting = meeting(10L, registry, "KS april"); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.createMeeting( + eq(1L), + eq("KS april"), + eq(LocalDateTime.parse("2026-05-10T13:00")), + eq(LocalDateTime.parse("2026-05-10T15:00")), + eq("Sessionssalen"), + eq("Anteckning") + )).thenReturn(createdMeeting); + + when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of(createdMeeting)); + when(meetingService.getMeetingById(10L)).thenReturn(createdMeeting); + when(meetingService.getAgendaItems(10L)).thenReturn(List.of()); + when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + mockMvc.perform(post("/admin/meetings") + .header("HX-Request", "true") + .param("registryId", "1") + .param("title", "KS april") + .param("startsAt", "2026-05-10T13:00") + .param("endsAt", "2026-05-10T15:00") + .param("location", "Sessionssalen") + .param("notes", "Anteckning")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attribute("successMessage", "sammanträdet skapades.")); + + verify(meetingService).createMeeting( + 1L, + "KS april", + LocalDateTime.parse("2026-05-10T13:00"), + LocalDateTime.parse("2026-05-10T15:00"), + "Sessionssalen", + "Anteckning" + ); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("POST /admin/meetings should return error message when service throws") + void createMeeting_shouldReturnFragmentAndErrorMessage_whenServiceThrows() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.createMeeting( + any(), + any(), + any(), + any(), + any(), + any() + )).thenThrow(new InvalidMeetingStateException("Titel måste anges.")); + when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of()); + + mockMvc.perform(post("/admin/meetings") + .with(csrf()) + .header("HX-Request", "true") + .param("registryId", "1") + .param("title", " ") + .param("startsAt", "2026-05-10T13:00")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attribute("errorMessage", "Titel måste anges.")); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("GET /admin/meetings/{meetingId} should show selected meeting") + void showMeeting_shouldReturnFragmentWithSelectedMeeting() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + Meeting meeting = meeting(10L, registry, "KS april"); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.getMeetingById(10L)).thenReturn(meeting); + when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of(meeting)); + when(meetingService.getAgendaItems(10L)).thenReturn(List.of()); + when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + mockMvc.perform(get("/admin/meetings/10") + .header("HX-Request", "true")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attribute("selectedMeeting", meeting)); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("POST /admin/meetings/{meetingId}/agenda-items should add agenda item") + void addAgendaItem_shouldReturnFragmentAndSuccessMessage() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + Meeting meeting = meeting(10L, registry, "KS april"); + CaseRecord caseRecord = mock(CaseRecord.class); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.getMeetingById(10L)).thenReturn(meeting); + when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of(meeting)); + when(meetingService.getAgendaItems(10L)).thenReturn(List.of()); + when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of(caseRecord)); + + mockMvc.perform(post("/admin/meetings/10/agenda-items") + .header("HX-Request", "true") + .param("caseRecordId", "100")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attribute("successMessage", "ärendet lades till på sammanträdet.")); + + verify(meetingService).addCaseRecordToMeeting(10L, 100L); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("POST /admin/meetings/{meetingId}/update should update meeting") + void updateMeeting_shouldReturnFragmentAndSuccessMessage() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + Meeting meeting = meeting(10L, registry, "KS april"); + Meeting updatedMeeting = meeting(10L, registry, "KS april uppdaterad"); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.updateMeeting( + eq(10L), + eq("KS april uppdaterad"), + eq(LocalDateTime.parse("2026-05-10T13:00")), + eq(LocalDateTime.parse("2026-05-10T15:00")), + eq("Sessionssalen"), + eq("Nya anteckningar"), + eq(MeetingStatus.PREPARING) + )).thenReturn(updatedMeeting); + + when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of(updatedMeeting)); + when(meetingService.getMeetingById(10L)).thenReturn(updatedMeeting); + when(meetingService.getAgendaItems(10L)).thenReturn(List.of()); + when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + mockMvc.perform(post("/admin/meetings/10/update") + .header("HX-Request", "true") + .param("title", "KS april uppdaterad") + .param("startsAt", "2026-05-10T13:00") + .param("endsAt", "2026-05-10T15:00") + .param("location", "Sessionssalen") + .param("notes", "Nya anteckningar") + .param("status", "PREPARING")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attribute("successMessage", "sammanträdet uppdaterades.")); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("POST /admin/meetings/{meetingId}/delete should delete meeting") + void deleteMeeting_shouldReturnFragmentAndSuccessMessage() throws Exception { + Registry registry = registry(1L, "Kommunstyrelsen", "KS"); + Meeting meeting = meeting(10L, registry, "KS april"); + + when(registryRepository.findAll()).thenReturn(List.of(registry)); + when(meetingService.getMeetingById(10L)).thenReturn(meeting); + when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of()); + + mockMvc.perform(post("/admin/meetings/10/delete") + .header("HX-Request", "true")) + .andExpect(status().isOk()) + .andExpect(view().name("fragments/admin-meetings :: content")) + .andExpect(model().attribute("successMessage", "sammanträdet togs bort.")); + + verify(meetingService).deleteMeeting(10L); + } + + private Registry registry(Long id, String name, String code) { + Registry registry = new Registry(name, code); + setField(registry, "id", id); + return registry; + } + + private Meeting meeting(Long id, Registry registry, String title) { + Meeting meeting = new Meeting( + registry, + title, + LocalDateTime.of(2026, 5, 10, 13, 0), + LocalDateTime.of(2026, 5, 10, 15, 0), + "Sessionssalen", + MeetingStatus.PLANNED, + "Anteckning" + ); + setField(meeting, "id", id); + return meeting; + } + + private void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} From fd7c0b96dae98c0c8bdf654617cbc4bf1695aa69 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 09:03:52 +0200 Subject: [PATCH 26/47] Added check and exception when trying to delete files in use --- .../team4you/casefile/CaseFileService.java | 16 ++++++++++------ .../team4you/exceptions/FileInUseException.java | 7 +++++++ .../exceptions/GlobalRestExceptionHandler.java | 6 ++++++ .../meeting/MeetingAgendaDocumentRepository.java | 2 ++ 4 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 src/main/java/backendlab/team4you/exceptions/FileInUseException.java diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileService.java b/src/main/java/backendlab/team4you/casefile/CaseFileService.java index 27c1f942..eeb4a76d 100644 --- a/src/main/java/backendlab/team4you/casefile/CaseFileService.java +++ b/src/main/java/backendlab/team4you/casefile/CaseFileService.java @@ -4,11 +4,8 @@ import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; import backendlab.team4you.common.ConfidentialityLevel; -import backendlab.team4you.exceptions.CaseFileNotFoundException; -import backendlab.team4you.exceptions.CaseRecordNotFoundException; -import backendlab.team4you.exceptions.FileStorageConfigurationException; -import backendlab.team4you.exceptions.FileTooLargeException; -import backendlab.team4you.exceptions.InvalidFileNameException; +import backendlab.team4you.exceptions.*; +import backendlab.team4you.meeting.MeetingAgendaDocumentRepository; import backendlab.team4you.s3.S3Service; import backendlab.team4you.user.UserEntity; import org.slf4j.Logger; @@ -38,17 +35,20 @@ public class CaseFileService { private final CaseFileRepository caseFileRepository; private final CaseFileAccessService caseFileAccessService; private final S3Service s3Service; + private final MeetingAgendaDocumentRepository meetingAgendaDocumentRepository; public CaseFileService( CaseRecordRepository caseRecordRepository, CaseFileRepository caseFileRepository, CaseFileAccessService caseFileAccessService, - S3Service s3Service + S3Service s3Service, + MeetingAgendaDocumentRepository meetingAgendaDocumentRepository ) { this.caseRecordRepository = caseRecordRepository; this.caseFileRepository = caseFileRepository; this.caseFileAccessService = caseFileAccessService; this.s3Service = s3Service; + this.meetingAgendaDocumentRepository = meetingAgendaDocumentRepository; } @Transactional @@ -180,6 +180,10 @@ public void deleteFile(Long caseRecordId, Long fileId, UserEntity actor) { } String s3Key = caseFile.getS3Key(); + if (meetingAgendaDocumentRepository.existsByCaseFileId(fileId)) { + throw new FileInUseException("Filen kan inte tas bort eftersom den används som mötesunderlag."); + } + caseFileRepository.delete(caseFile); try { s3Service.deleteFile(s3Key); diff --git a/src/main/java/backendlab/team4you/exceptions/FileInUseException.java b/src/main/java/backendlab/team4you/exceptions/FileInUseException.java new file mode 100644 index 00000000..9df935ca --- /dev/null +++ b/src/main/java/backendlab/team4you/exceptions/FileInUseException.java @@ -0,0 +1,7 @@ +package backendlab.team4you.exceptions; + +public class FileInUseException extends RuntimeException { + public FileInUseException(String message) { + super(message); + } +} diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java index 47246d4a..ffa0e956 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java @@ -205,4 +205,10 @@ public ResponseEntity handleUnexpected(Exception ex) { LocalDateTime.now() )); } + + @ExceptionHandler(FileInUseException.class) + public ResponseEntity handleFileInUse(FileInUseException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ex.getMessage()); + } } diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java index afcd3c71..a0953c38 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java @@ -13,4 +13,6 @@ public interface MeetingAgendaDocumentRepository extends JpaRepository findByAgendaItemAndCaseFile(MeetingAgendaItem agendaItem, CaseFile caseFile); boolean existsByAgendaItemAndCaseFile(MeetingAgendaItem agendaItem, CaseFile caseFile); + + boolean existsByCaseFileId(Long caseFileId); } From 28998e98d54adbcc4cb966d4e50fd2e89c6b898d Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 09:43:53 +0200 Subject: [PATCH 27/47] Added FileInUseException to deleteCaseFile --- .../team4you/casefile/ui/CaseFileViewController.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java b/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java index 64cc4ea6..378d92c1 100644 --- a/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java +++ b/src/main/java/backendlab/team4you/casefile/ui/CaseFileViewController.java @@ -2,11 +2,7 @@ import backendlab.team4you.casefile.CaseFileService; import backendlab.team4you.common.ConfidentialityLevel; -import backendlab.team4you.exceptions.CaseFileNotFoundException; -import backendlab.team4you.exceptions.CaseRecordNotFoundException; -import backendlab.team4you.exceptions.FileStorageConfigurationException; -import backendlab.team4you.exceptions.FileTooLargeException; -import backendlab.team4you.exceptions.InvalidFileNameException; +import backendlab.team4you.exceptions.*; import backendlab.team4you.user.UserEntity; import backendlab.team4you.user.UserService; import org.slf4j.Logger; @@ -89,6 +85,8 @@ public String deleteCaseFile( model.addAttribute("errorMessage", "Filen kunde inte hittas."); } catch (org.springframework.security.access.AccessDeniedException ex) { model.addAttribute("errorMessage", "Du har inte behörighet att ta bort den här filen."); + } catch (FileInUseException ex) { + model.addAttribute("errorMessage", ex.getMessage()); } catch (Exception ex) { log.error("Unexpected error while deleting fileId={} for caseId={}", fileId, caseId, ex); model.addAttribute("errorMessage", "Något gick fel när filen skulle tas bort."); From 4c91d6bbfa70700f6968b3f711f693dea2bee157 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 09:56:55 +0200 Subject: [PATCH 28/47] Added mock to fix tests and added new test for delete file in use --- .../casefile/CaseFileServiceTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java index d42a5021..bfca6b1b 100644 --- a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java +++ b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java @@ -5,6 +5,7 @@ import backendlab.team4you.caserecord.CaseRecordRepository; import backendlab.team4you.common.ConfidentialityLevel; import backendlab.team4you.exceptions.*; +import backendlab.team4you.meeting.MeetingAgendaDocumentRepository; import backendlab.team4you.s3.S3Service; import backendlab.team4you.user.UserEntity; import backendlab.team4you.user.UserRole; @@ -46,6 +47,9 @@ class CaseFileServiceTest { @Mock private CaseFileAccessService caseFileAccessService; + @Mock + private MeetingAgendaDocumentRepository meetingAgendaDocumentRepository; + @InjectMocks private CaseFileService caseFileService; @@ -641,4 +645,27 @@ void listFileItemsForViewer_shouldShowOpenFileNormally() { assertThat(result.get(0).canDownload()).isTrue(); assertThat(result.get(0).confidential()).isFalse(); } + + @Test + @DisplayName("delete file should throw FilInUseException when file is in use by a meeting") + void deleteFile_shouldThrowFileInUseException_whenFileIsUsedByMeetingAgendaDocument() { + CaseFile caseFile = new CaseFile(); + caseFile.setId(100L); + caseFile.setCaseRecord(caseRecord); + caseFile.setS3Key("cases/1/uuid-test.pdf"); + + when(caseFileRepository.findByIdAndCaseRecordId(100L, 1L)) + .thenReturn(Optional.of(caseFile)); + when(caseFileAccessService.canDeleteFile(actor, caseFile)) + .thenReturn(true); + when(meetingAgendaDocumentRepository.existsByCaseFileId(100L)) + .thenReturn(true); + + assertThatThrownBy(() -> caseFileService.deleteFile(1L, 100L, actor)) + .isInstanceOf(FileInUseException.class) + .hasMessageContaining("mötesunderlag"); + + verify(caseFileRepository, never()).delete(any()); + verify(s3Service, never()).deleteFile(anyString()); + } } \ No newline at end of file From 815bfdf7fe79cd5564bffab7c99bb45c94a80846 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 11:57:40 +0200 Subject: [PATCH 29/47] removed localstack_data to adress coderabbit feedback Named volume without reliable persistence won't preserve LocalStack state across container restarts in Community Edition. --- docker-compose.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 1c5aa042..ed2ce941 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -22,12 +22,11 @@ services: AWS_BUCKET_NAME: ${AWS_BUCKET_NAME:-team4you-files} volumes: - ./init-localstack.sh:/etc/localstack/init/ready.d/init-localstack.sh - - localstack_data:/var/lib/localstack healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] interval: 10s timeout: 5s retries: 5 + volumes: - postgres_data: - localstack_data: \ No newline at end of file + postgres_data: \ No newline at end of file From a3be5d6bca75575e7f61d23dffae4a162067014f Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:04:35 +0200 Subject: [PATCH 30/47] fixed coderabbit feedback: Add MeetingAgendaDocumentRepository mock to CaseFileServiceTest. --- .../backendlab/team4you/casefile/CaseFileServiceTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java index bfca6b1b..bc97dc8e 100644 --- a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java +++ b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java @@ -322,6 +322,7 @@ void deleteFile_shouldDeleteFromS3AndRepository_whenActorHasPermission() { when(caseFileRepository.findByIdAndCaseRecordId(100L, 1L)) .thenReturn(Optional.of(caseFile)); when(caseFileAccessService.canDeleteFile(actor, caseFile)).thenReturn(true); + when(meetingAgendaDocumentRepository.existsByCaseFileId(100L)).thenReturn(false); caseFileService.deleteFile(1L, 100L, actor); @@ -475,6 +476,8 @@ void deleteFile_shouldDeleteMetadataBeforeAttemptingS3Delete() { when(caseFileRepository.findByIdAndCaseRecordId(100L, 1L)) .thenReturn(Optional.of(caseFile)); when(caseFileAccessService.canDeleteFile(actor, caseFile)).thenReturn(true); + when(meetingAgendaDocumentRepository.existsByCaseFileId(100L)) + .thenReturn(false); doThrow(new RuntimeException("s3 delete failed")) .when(s3Service) @@ -647,7 +650,7 @@ void listFileItemsForViewer_shouldShowOpenFileNormally() { } @Test - @DisplayName("delete file should throw FilInUseException when file is in use by a meeting") + @DisplayName("delete file should throw FileInUseException when file is in use by a meeting") void deleteFile_shouldThrowFileInUseException_whenFileIsUsedByMeetingAgendaDocument() { CaseFile caseFile = new CaseFile(); caseFile.setId(100L); From dac15cc690f77345506f19fa4ca7d82f0b6f76c8 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:06:56 +0200 Subject: [PATCH 31/47] =?UTF-8?q?fixed=20coderabbit=20feedback:=20Inconsis?= =?UTF-8?q?tent=20error=20response=20shape=20=E2=80=94=20return=20ErrorRes?= =?UTF-8?q?ponseDto=20like=20the=20other=20handlers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team4you/exceptions/GlobalRestExceptionHandler.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java index ffa0e956..41cbd467 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalRestExceptionHandler.java @@ -207,8 +207,13 @@ public ResponseEntity handleUnexpected(Exception ex) { } @ExceptionHandler(FileInUseException.class) - public ResponseEntity handleFileInUse(FileInUseException ex) { + public ResponseEntity handleFileInUse(FileInUseException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) - .body(ex.getMessage()); + .body(new ErrorResponseDto( + HttpStatus.CONFLICT.value(), + "conflict", + ex.getMessage(), + LocalDateTime.now() + )); } } From 1862f5f9432479a666ac38d79a96fbe8eaac23b1 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:10:07 +0200 Subject: [PATCH 32/47] =?UTF-8?q?fixed=20coderabbit=20feedback:=20Inconsis?= =?UTF-8?q?tent=20error=20response=20shape=20=E2=80=94=20return=20ErrorRes?= =?UTF-8?q?ponseDto=20like=20the=20other=20handlers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team4you/exceptions/GlobalViewExceptionHandler.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java index 2ae47814..33f5399b 100644 --- a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java +++ b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java @@ -31,7 +31,9 @@ public String handleDuplicateEmail(DuplicateEmailException ex, Model model) { @ExceptionHandler({ CaseRecordNotFoundException.class, RegistryNotFoundException.class, - CaseFileNotFoundException.class + CaseFileNotFoundException.class, + MeetingNotFoundException.class, + MeetingAgendaDocumentNotFoundException.class, }) @ResponseStatus(HttpStatus.NOT_FOUND) public String handleNotFound(RuntimeException ex, Model model) { From 587db6e054fb83d274adbb81294dd63470c4c92b Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:13:59 +0200 Subject: [PATCH 33/47] =?UTF-8?q?fixed=20coderabbit=20feedback:=20Remove?= =?UTF-8?q?=20malformed=20findById(Meeting,=20CaseRecord)=20method=20?= =?UTF-8?q?=E2=80=94=20breaks=20Spring=20Data=20startup.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team4you/meeting/MeetingAgendaItemRepository.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java index 61310f2f..f8a9ef34 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java @@ -10,8 +10,6 @@ public interface MeetingAgendaItemRepository extends JpaRepository findByMeetingOrderByAgendaOrderAsc(Meeting meeting); - Optional findById(Meeting meeting, CaseRecord caseRecord); - boolean existsByMeetingAndCaseRecord(Meeting meeting, CaseRecord caseRecord); long countByMeeting(Meeting meeting); From f6bffdefab6a602a1388d7588c6bb580bc4f705c Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:16:07 +0200 Subject: [PATCH 34/47] fixed coderabbit feedback: Inconsistent capitalization in success/error messages. --- .../backendlab/team4you/meeting/MeetingController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 1e5be483..9e74ac80 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -198,7 +198,7 @@ public String moveAgendaItemUp( try { meetingService.moveAgendaItemUp(meetingId, agendaItemId); - model.addAttribute("successMessage", "Dagordningspunkten flyttades upp."); + model.addAttribute("successMessage", "dagordningspunkten flyttades upp."); } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); } @@ -217,7 +217,7 @@ public String moveAgendaItemDown( try { meetingService.moveAgendaItemDown(meetingId, agendaItemId); - model.addAttribute("successMessage", "Dagordningspunkten flyttades ner."); + model.addAttribute("successMessage", "dagordningspunkten flyttades ner."); } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); } @@ -262,7 +262,7 @@ public String addAgendaDocument( try { meetingService.addDocumentToAgendaItem(meetingId, agendaItemId, caseFileId); - model.addAttribute("successMessage", "Handlingen lades till som beslutsunderlag."); + model.addAttribute("successMessage", "handlingen lades till som beslutsunderlag."); } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); } @@ -282,7 +282,7 @@ public String removeAgendaDocument( try { meetingService.removeDocumentFromAgendaItem(meetingId, agendaItemId, documentId); - model.addAttribute("successMessage", "Handlingen togs bort från beslutsunderlaget."); + model.addAttribute("successMessage", "handlingen togs bort från beslutsunderlaget."); } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); } From a6a76c70c2f5ea11408e89236ab848dedfbf5ece Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:19:01 +0200 Subject: [PATCH 35/47] fixed coderabbit feedback: Catch block can throw, losing the original error. --- .../team4you/meeting/MeetingController.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 9e74ac80..8c481558 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -116,11 +116,16 @@ public String updateMeeting( model.addAttribute("successMessage", "sammanträdet uppdaterades."); populateMeetingsPage(model, updatedMeeting.getRegistry().getId(), updatedMeeting.getId()); - } catch (Exception exception) { - Meeting meeting = meetingService.getMeetingById(meetingId); + } catch (InvalidMeetingStateException | MeetingNotFoundException | RegistryNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); - } + Long registryId = null; + try { + registryId = meetingService.getMeetingById(meetingId).getRegistry().getId(); + } catch (MeetingNotFoundException ignored) { + // meeting no longer exists — fall back to the generic listing + } + populateMeetingsPage(model, registryId, registryId == null ? null : meetingId); + } return "fragments/admin-meetings :: content"; } From dc1c2d17abfc5cdbea347557f31fa4eba7685917 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:23:21 +0200 Subject: [PATCH 36/47] fixed coderabbit feedback: Narrow catch (IllegalArgumentException) misses the expected domain exceptions. --- .../team4you/meeting/MeetingController.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 8c481558..6b181b64 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -3,9 +3,7 @@ import backendlab.team4you.casefile.CaseFile; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; -import backendlab.team4you.exceptions.InvalidMeetingStateException; -import backendlab.team4you.exceptions.MeetingNotFoundException; -import backendlab.team4you.exceptions.RegistryNotFoundException; +import backendlab.team4you.exceptions.*; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.springframework.stereotype.Controller; @@ -180,7 +178,9 @@ public String addAgendaItem( try { meetingService.addCaseRecordToMeeting(meetingId, caseRecordId); model.addAttribute("successMessage", "ärendet lades till på sammanträdet."); - } catch (IllegalArgumentException exception) { + } catch (DuplicateMeetingAgendaItemException | + InvalidMeetingStateException | + MeetingNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); } @@ -243,7 +243,9 @@ public String removeAgendaItem( try { meetingService.removeAgendaItem(meetingId, agendaItemId); model.addAttribute("successMessage", "dagordningspunkten togs bort."); - } catch (IllegalArgumentException exception) { + } catch (InvalidMeetingStateException | + MeetingAgendaItemNotFoundException | + MeetingNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); } From 0652fc94d206e7ba9a8d6989ad1f35e8b70357b3 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:24:28 +0200 Subject: [PATCH 37/47] =?UTF-8?q?fixed=20coderabbit=20feedback:=20Typo=20i?= =?UTF-8?q?n=20user-facing=20message:=20"diarum"=20=E2=86=92=20"diarium".?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/backendlab/team4you/meeting/MeetingService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 8cee3957..66294b6e 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -336,7 +336,7 @@ public Meeting updateMeetingStatus(Long meetingId, MeetingStatus status) { private void validateCaseRecordBelongsToMeetingRegistry(Meeting meeting, CaseRecord caseRecord) { if (caseRecord.getRegistry() == null || caseRecord.getRegistry().getId() == null) { - throw new InvalidMeetingStateException("Ärendet saknar diarum."); + throw new InvalidMeetingStateException("Ärendet saknar diarium."); } if (!caseRecord.getRegistry().getId().equals(meeting.getRegistry().getId())) { From 7f32aa6be8b23fdee53bc4688ce32c711208abcb Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:28:06 +0200 Subject: [PATCH 38/47] fixed coderabbit feedback: resequenceAgendaItems risks violating uk_meeting_agenda_item_meeting_order constraint during deletion --- ...20__make_meeting_agenda_order_constraint_deferrable.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/resources/db/migration/V20__make_meeting_agenda_order_constraint_deferrable.sql diff --git a/src/main/resources/db/migration/V20__make_meeting_agenda_order_constraint_deferrable.sql b/src/main/resources/db/migration/V20__make_meeting_agenda_order_constraint_deferrable.sql new file mode 100644 index 00000000..d417bc69 --- /dev/null +++ b/src/main/resources/db/migration/V20__make_meeting_agenda_order_constraint_deferrable.sql @@ -0,0 +1,7 @@ +ALTER TABLE meeting_agenda_item + DROP CONSTRAINT uk_meeting_agenda_item_meeting_order; + +ALTER TABLE meeting_agenda_item + ADD CONSTRAINT uk_meeting_agenda_item_meeting_order + UNIQUE (meeting_id, agenda_order) + DEFERRABLE INITIALLY IMMEDIATE; \ No newline at end of file From 4c9ddbec2c39ea499f8808939555a5417866f252 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:29:44 +0200 Subject: [PATCH 39/47] fixed coderabbit feedback: Label inconsistency: "Registry:" is English in an otherwise Swedish UI. --- src/main/resources/templates/fragments/admin-meetings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 6c381cd7..710a697a 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -95,7 +95,7 @@

    Välj ett sammanträde

    Sammanträde

    - Registry: + Diarium: Kommunstyrelsen

    From 00849ea6696d780d0acf21a32d2259487cfff019 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 12:38:12 +0200 Subject: [PATCH 40/47] fixed coderabbit feedback: Inconsistent CSRF handling across POST tests. --- .../backendlab/team4you/meeting/MeetingControllerTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java index 905ab88e..80cf401c 100644 --- a/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java +++ b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java @@ -84,6 +84,7 @@ void createMeeting_shouldReturnFragmentAndSuccessMessage_whenRequestIsValid() th when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); mockMvc.perform(post("/admin/meetings") + .with(csrf()) .header("HX-Request", "true") .param("registryId", "1") .param("title", "KS april") @@ -168,6 +169,7 @@ void addAgendaItem_shouldReturnFragmentAndSuccessMessage() throws Exception { when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of(caseRecord)); mockMvc.perform(post("/admin/meetings/10/agenda-items") + .with(csrf()) .header("HX-Request", "true") .param("caseRecordId", "100")) .andExpect(status().isOk()) @@ -202,6 +204,7 @@ void updateMeeting_shouldReturnFragmentAndSuccessMessage() throws Exception { when(caseRecordRepository.findByRegistryIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); mockMvc.perform(post("/admin/meetings/10/update") + .with(csrf()) .header("HX-Request", "true") .param("title", "KS april uppdaterad") .param("startsAt", "2026-05-10T13:00") @@ -226,6 +229,7 @@ void deleteMeeting_shouldReturnFragmentAndSuccessMessage() throws Exception { when(meetingService.getMeetingsForRegistry(1L)).thenReturn(List.of()); mockMvc.perform(post("/admin/meetings/10/delete") + .with(csrf()) .header("HX-Request", "true")) .andExpect(status().isOk()) .andExpect(view().name("fragments/admin-meetings :: content")) From 2f4cc0bdd8b5be38fcbd25b9a566edac6f7c7053 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 15:08:57 +0200 Subject: [PATCH 41/47] fixed coderabbit feedback: createMeeting catch can cascade to 500 when registryId is invalid. --- .../team4you/meeting/MeetingController.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 6b181b64..ada55e96 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -74,7 +74,17 @@ public String createMeeting( } catch (InvalidMeetingStateException | MeetingNotFoundException | RegistryNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); - populateMeetingsPage(model, registryId, null); + // If registryId was the invalid input, fall back to the all-meetings listing. + Long safeRegistryId = null; + if (registryId != null) { + try { + registryRepository.findById(registryId).ifPresent(r -> {}); + safeRegistryId = registryId; + } catch (Exception ignored) { + safeRegistryId = null; + } + } + populateMeetingsPage(model, safeRegistryId, null); } if (htmx != null) { From 52a5683873de84f9f936b8692465565673516797 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 15:17:35 +0200 Subject: [PATCH 42/47] fixed coderabbit feedback: meetingService.getMeetingById(...) is called outside the try block in several endpoints. --- .../team4you/meeting/MeetingController.java | 76 ++++++++++++------- .../meeting/MeetingControllerTest.java | 2 +- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index ada55e96..9a7c46ac 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -183,19 +183,21 @@ public String addAgendaItem( @RequestHeader(value = "HX-Request", required = false) String htmx, Model model ) { - Meeting meeting = meetingService.getMeetingById(meetingId); - try { + Meeting meeting = meetingService.getMeetingById(meetingId); + meetingService.addCaseRecordToMeeting(meetingId, caseRecordId); - model.addAttribute("successMessage", "ärendet lades till på sammanträdet."); + model.addAttribute("successMessage", "Ärendet lades till på sammanträdet."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + } catch (DuplicateMeetingAgendaItemException | InvalidMeetingStateException | MeetingNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); } - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); - if (htmx != null) { return "fragments/admin-meetings :: content"; } @@ -209,16 +211,19 @@ public String moveAgendaItemUp( @PathVariable Long agendaItemId, Model model ) { - Meeting meeting = meetingService.getMeetingById(meetingId); - try { + Meeting meeting = meetingService.getMeetingById(meetingId); + meetingService.moveAgendaItemUp(meetingId, agendaItemId); - model.addAttribute("successMessage", "dagordningspunkten flyttades upp."); + model.addAttribute("successMessage", "Dagordningspunkten flyttades upp."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); } - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); return "fragments/admin-meetings :: content"; } @@ -228,16 +233,19 @@ public String moveAgendaItemDown( @PathVariable Long agendaItemId, Model model ) { - Meeting meeting = meetingService.getMeetingById(meetingId); - try { + Meeting meeting = meetingService.getMeetingById(meetingId); + meetingService.moveAgendaItemDown(meetingId, agendaItemId); - model.addAttribute("successMessage", "dagordningspunkten flyttades ner."); + model.addAttribute("successMessage", "Dagordningspunkten flyttades ner."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); } - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); return "fragments/admin-meetings :: content"; } @@ -248,19 +256,21 @@ public String removeAgendaItem( @RequestHeader(value = "HX-Request", required = false) String htmx, Model model ) { - Meeting meeting = meetingService.getMeetingById(meetingId); - try { + Meeting meeting = meetingService.getMeetingById(meetingId); + meetingService.removeAgendaItem(meetingId, agendaItemId); - model.addAttribute("successMessage", "dagordningspunkten togs bort."); + model.addAttribute("successMessage", "Dagordningspunkten togs bort."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + } catch (InvalidMeetingStateException | MeetingAgendaItemNotFoundException | MeetingNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); } - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); - if (htmx != null) { return "fragments/admin-meetings :: content"; } @@ -275,16 +285,19 @@ public String addAgendaDocument( @RequestParam Long caseFileId, Model model ) { - Meeting meeting = meetingService.getMeetingById(meetingId); - try { + Meeting meeting = meetingService.getMeetingById(meetingId); + meetingService.addDocumentToAgendaItem(meetingId, agendaItemId, caseFileId); - model.addAttribute("successMessage", "handlingen lades till som beslutsunderlag."); + model.addAttribute("successMessage", "Handlingen lades till som beslutsunderlag."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); } - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); return "fragments/admin-meetings :: content"; } @@ -295,19 +308,30 @@ public String removeAgendaDocument( @PathVariable Long documentId, Model model ) { - Meeting meeting = meetingService.getMeetingById(meetingId); - try { + Meeting meeting = meetingService.getMeetingById(meetingId); + meetingService.removeDocumentFromAgendaItem(meetingId, agendaItemId, documentId); - model.addAttribute("successMessage", "handlingen togs bort från beslutsunderlaget."); + model.addAttribute("successMessage", "Handlingen togs bort från beslutsunderlaget."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + } catch (Exception exception) { model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); } - populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); return "fragments/admin-meetings :: content"; } + private void populateMeetingsPageAfterMeetingAction(Model model, Long registryId, Long meetingId) { + try { + populateMeetingsPage(model, registryId, meetingId); + } catch (MeetingNotFoundException exception) { + populateMeetingsPage(model, null, null); + } + } + private void populateMeetingsPage(Model model, Long registryId, Long selectedMeetingId) { List registries = registryRepository.findAll(); model.addAttribute("registries", registries); diff --git a/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java index 80cf401c..c195c590 100644 --- a/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java +++ b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java @@ -174,7 +174,7 @@ void addAgendaItem_shouldReturnFragmentAndSuccessMessage() throws Exception { .param("caseRecordId", "100")) .andExpect(status().isOk()) .andExpect(view().name("fragments/admin-meetings :: content")) - .andExpect(model().attribute("successMessage", "ärendet lades till på sammanträdet.")); + .andExpect(model().attribute("successMessage", "Ärendet lades till på sammanträdet.")); verify(meetingService).addCaseRecordToMeeting(10L, 100L); } From ab51517a073a6e730454010afcb59c1073af68d6 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 15:26:51 +0200 Subject: [PATCH 43/47] fixed coderabbit feedback: Inconsistent exception types for not-found lookups produce wrong HTTP status. --- .../java/backendlab/team4you/meeting/MeetingService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 66294b6e..41a0f3f3 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -138,7 +138,7 @@ public void deleteMeeting(Long meetingId) { @Transactional(readOnly = true) public List getMeetingsForRegistry(Long registryId) { Registry registry = registryRepository.findById(registryId) - .orElseThrow(() -> new IllegalArgumentException("Registry hittades inte.")); + .orElseThrow(() -> new RegistryNotFoundException("Registry hittades inte.")); return meetingRepository.findByRegistryOrderByStartsAtDesc(registry); } @@ -172,7 +172,7 @@ public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordI Meeting meeting = getMeetingById(meetingId); CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) - .orElseThrow(() -> new IllegalArgumentException("Ärendet hittades inte.")); + .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); validateCaseRecordBelongsToMeetingRegistry(meeting, caseRecord); @@ -314,7 +314,7 @@ public void removeDocumentFromAgendaItem(Long meetingId, Long agendaItemId, Long } MeetingAgendaDocument document = meetingAgendaDocumentRepository.findById(documentId) - .orElseThrow(() -> new IllegalArgumentException("Dokumentkopplingen hittades inte.")); + .orElseThrow(() -> new MeetingAgendaDocumentNotFoundException("Dokumentkopplingen hittades inte.")); if (!document.getAgendaItem().getId().equals(agendaItem.getId())) { throw new InvalidMeetingStateException("Dokumentet tillhör inte denna dagordningspunkt."); From d2ca6b5aa1aa06405f24617d2a2aef6cf6f37df7 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 15:40:09 +0200 Subject: [PATCH 44/47] =?UTF-8?q?fixed=20coderabbit=20feedback:=20Raw=20en?= =?UTF-8?q?um=20names=20("PLANNED",=20"COMPLETED",=20=E2=80=A6)=20leak=20i?= =?UTF-8?q?nto=20the=20Swedish=20UI.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team4you/meeting/MeetingStatus.java | 18 ++++++++++++++---- .../templates/fragments/admin-meetings.html | 6 +++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingStatus.java b/src/main/java/backendlab/team4you/meeting/MeetingStatus.java index 9f503662..1f4a5caf 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingStatus.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingStatus.java @@ -2,8 +2,18 @@ public enum MeetingStatus { - PLANNED, - PREPARING, - COMPLETED, - CANCELLED + PLANNED("Planerad"), + PREPARING("Under förberedelse"), + COMPLETED("Avslutad"), + CANCELLED("Inställd"); + + private final String label; + + MeetingStatus(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } } diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 710a697a..0da0d9d1 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -116,7 +116,7 @@

    Sammanträde

    Status: - PLANNED + Planerad

    @@ -171,8 +171,8 @@

    Redigera sammanträde

    From 388995226408042d3cf6770df10a364f66a8757c Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 17:10:24 +0200 Subject: [PATCH 45/47] =?UTF-8?q?fixed=20coderabbit=20feedback:=20The=20sa?= =?UTF-8?q?feRegistryId=20fallback=20does=20nothing=20=E2=80=94=20populate?= =?UTF-8?q?MeetingsPage=20will=20re-throw=20on=20an=20invalid=20registryId?= =?UTF-8?q?=20and=20populateMeetingsPageAfterMeetingAction=20won't=20swall?= =?UTF-8?q?ow=20RegistryNotFoundException.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team4you/meeting/MeetingController.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingController.java b/src/main/java/backendlab/team4you/meeting/MeetingController.java index 9a7c46ac..4f47740c 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingController.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -75,15 +75,9 @@ public String createMeeting( } catch (InvalidMeetingStateException | MeetingNotFoundException | RegistryNotFoundException exception) { model.addAttribute("errorMessage", exception.getMessage()); // If registryId was the invalid input, fall back to the all-meetings listing. - Long safeRegistryId = null; - if (registryId != null) { - try { - registryRepository.findById(registryId).ifPresent(r -> {}); - safeRegistryId = registryId; - } catch (Exception ignored) { - safeRegistryId = null; - } - } + Long safeRegistryId = (registryId != null && registryRepository.existsById(registryId)) + ? registryId + : null; populateMeetingsPage(model, safeRegistryId, null); } @@ -327,7 +321,7 @@ public String removeAgendaDocument( private void populateMeetingsPageAfterMeetingAction(Model model, Long registryId, Long meetingId) { try { populateMeetingsPage(model, registryId, meetingId); - } catch (MeetingNotFoundException exception) { + } catch (MeetingNotFoundException | RegistryNotFoundException exception) { populateMeetingsPage(model, null, null); } } From 8adc1f49acd4411680388aa045a41a7bf5b57aae Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 17:37:42 +0200 Subject: [PATCH 46/47] fixed coderabbit feedback: Replace with correct exception type for CaseFile lookup. --- .../team4you/meeting/MeetingService.java | 7 ++++-- .../team4you/meeting/MeetingServiceTest.java | 25 ++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/backendlab/team4you/meeting/MeetingService.java b/src/main/java/backendlab/team4you/meeting/MeetingService.java index 41a0f3f3..e4a18e90 100644 --- a/src/main/java/backendlab/team4you/meeting/MeetingService.java +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -290,8 +290,11 @@ public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agenda throw new InvalidMeetingStateException("Dagordningspunkten tillhör inte detta sammanträde."); } - CaseFile caseFile = caseFileRepository.findById(caseFileId) - .orElseThrow(() -> new MeetingAgendaDocumentNotFoundException("Handlingen hittades inte.")); + Long caseRecordId = agendaItem.getCaseRecord().getId(); + + CaseFile caseFile = caseFileRepository + .findByIdAndCaseRecordId(caseFileId, caseRecordId) + .orElseThrow(() -> new CaseFileNotFoundException(caseRecordId, caseFileId)); validateCaseFileBelongsToAgendaItemCaseRecord(agendaItem, caseFile); diff --git a/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java index 4d1d6831..f4c60f70 100644 --- a/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java +++ b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java @@ -4,10 +4,7 @@ import backendlab.team4you.casefile.CaseFileRepository; import backendlab.team4you.caserecord.CaseRecord; import backendlab.team4you.caserecord.CaseRecordRepository; -import backendlab.team4you.exceptions.DuplicateMeetingAgendaDocumentException; -import backendlab.team4you.exceptions.DuplicateMeetingAgendaItemException; -import backendlab.team4you.exceptions.InvalidMeetingStateException; -import backendlab.team4you.exceptions.MeetingNotFoundException; +import backendlab.team4you.exceptions.*; import backendlab.team4you.registry.Registry; import backendlab.team4you.registry.RegistryRepository; import org.junit.jupiter.api.BeforeEach; @@ -237,10 +234,12 @@ void addDocumentToAgendaItem_shouldAddDocument_whenFileBelongsToSameCaseRecord() setField(agendaItem, "id", 50L); when(caseFile.getCaseRecord()).thenReturn(caseRecord); + when(caseRecord.getId()).thenReturn(100L); when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); - when(caseFileRepository.findById(1000L)).thenReturn(Optional.of(caseFile)); + when(caseFileRepository.findByIdAndCaseRecordId(1000L, 100L)) + .thenReturn(Optional.of(caseFile)); when(meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)).thenReturn(false); when(meetingAgendaDocumentRepository.save(any(MeetingAgendaDocument.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -258,10 +257,12 @@ void addDocumentToAgendaItem_shouldThrowDuplicateMeetingAgendaDocumentException_ setField(agendaItem, "id", 50L); when(caseFile.getCaseRecord()).thenReturn(caseRecord); + when(caseRecord.getId()).thenReturn(100L); when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); - when(caseFileRepository.findById(1000L)).thenReturn(Optional.of(caseFile)); + when(caseFileRepository.findByIdAndCaseRecordId(1000L, 100L)) + .thenReturn(Optional.of(caseFile)); when(meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)).thenReturn(true); assertThatThrownBy(() -> meetingService.addDocumentToAgendaItem(10L, 50L, 1000L)) @@ -270,22 +271,18 @@ void addDocumentToAgendaItem_shouldThrowDuplicateMeetingAgendaDocumentException_ } @Test - @DisplayName("addDocumentToAgendaItem should throw InvalidMeetingStateException when file belongs to wrong case record") - void addDocumentToAgendaItem_shouldThrowInvalidMeetingStateException_whenFileBelongsToWrongCaseRecord() { + @DisplayName("addDocumentToAgendaItem should throw CaseFileNotFoundException when file does not belong to agenda item's case record") + void addDocumentToAgendaItem_shouldThrowCaseFileNotFoundException_whenFileBelongsToWrongCaseRecord() { MeetingAgendaItem agendaItem = new MeetingAgendaItem(meeting, caseRecord, 1, null); setField(agendaItem, "id", 50L); when(caseRecord.getId()).thenReturn(100L); - when(otherRegistryCaseRecord.getId()).thenReturn(200L); - when(wrongCaseFile.getCaseRecord()).thenReturn(otherRegistryCaseRecord); - when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); - when(caseFileRepository.findById(2000L)).thenReturn(Optional.of(wrongCaseFile)); assertThatThrownBy(() -> meetingService.addDocumentToAgendaItem(10L, 50L, 2000L)) - .isInstanceOf(InvalidMeetingStateException.class) - .hasMessage("Handlingen tillhör inte ärendet på denna dagordningspunkt."); + .isInstanceOf(CaseFileNotFoundException.class) + .hasMessage("File not found for case record. caseRecordId=100, fileId=2000"); } @Test From 2e2b0aa58e445330476c4b353b05fafc0da14092 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Fri, 24 Apr 2026 17:43:11 +0200 Subject: [PATCH 47/47] fixed coderabbit feedback: Label "Instans" is inconsistent with the rest of the UI terminology. --- src/main/resources/templates/fragments/admin-meetings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/templates/fragments/admin-meetings.html b/src/main/resources/templates/fragments/admin-meetings.html index 0da0d9d1..d9dcee30 100644 --- a/src/main/resources/templates/fragments/admin-meetings.html +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -21,7 +21,7 @@

    Skapa sammanträde

    th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
    - +