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 diff --git a/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java b/src/main/java/backendlab/team4you/casefile/CaseFileRepository.java index 0691f527..eb309c10 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 findByCaseRecordIdOrderByUploadedAtDesc(Long caseRecordId); } 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/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."); 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/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 6e3530ec..41cbd467 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) @@ -199,4 +205,15 @@ public ResponseEntity handleUnexpected(Exception ex) { LocalDateTime.now() )); } + + @ExceptionHandler(FileInUseException.class) + public ResponseEntity handleFileInUse(FileInUseException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponseDto( + HttpStatus.CONFLICT.value(), + "conflict", + ex.getMessage(), + LocalDateTime.now() + )); + } } diff --git a/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java b/src/main/java/backendlab/team4you/exceptions/GlobalViewExceptionHandler.java index c59466a4..33f5399b 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); @@ -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) { 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/Meeting.java b/src/main/java/backendlab/team4you/meeting/Meeting.java new file mode 100644 index 00000000..ebfbbf09 --- /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/MeetingAgendaDocument.java b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java new file mode 100644 index 00000000..7fd50901 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocument.java @@ -0,0 +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; + } +} 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..a0953c38 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaDocumentRepository.java @@ -0,0 +1,18 @@ +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); + + boolean existsByCaseFileId(Long caseFileId); +} 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..80263afb --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItem.java @@ -0,0 +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; + } +} 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..f8a9ef34 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingAgendaItemRepository.java @@ -0,0 +1,20 @@ +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); + + boolean existsByMeetingAndCaseRecord(Meeting meeting, CaseRecord caseRecord); + + long countByMeeting(Meeting meeting); + + Optional findByMeetingAndAgendaOrder(Meeting meeting, Integer agendaOrder); + + Optional findByIdAndMeeting(Long id, Meeting meeting); +} 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..4f47740c --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingController.java @@ -0,0 +1,374 @@ +package backendlab.team4you.meeting; + +import backendlab.team4you.casefile.CaseFile; +import backendlab.team4you.caserecord.CaseRecord; +import backendlab.team4you.caserecord.CaseRecordRepository; +import backendlab.team4you.exceptions.*; +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 java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@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 + ) { + populateMeetingsPage(model, registryId, selectedMeetingId); + 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, + @RequestHeader(value = "HX-Request", required = false) String htmx, + 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."); + populateMeetingsPage(model, registryId, meeting.getId()); + + } 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 = (registryId != null && registryRepository.existsById(registryId)) + ? registryId + : null; + populateMeetingsPage(model, safeRegistryId, null); + } + + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } + + 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 (InvalidMeetingStateException | MeetingNotFoundException | RegistryNotFoundException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + 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"; + } + + @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, + @RequestHeader(value = "HX-Request", required = false) String htmx, + Model model + ) { + Meeting meeting = meetingService.getMeetingById(meetingId); + populateMeetingsPage(model, meeting.getRegistry().getId(), meetingId); + + if (htmx != null) { + 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 + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + + meetingService.addCaseRecordToMeeting(meetingId, caseRecordId); + 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); + } + + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } + + return "admin/meetings"; + } + + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/move-up") + public String moveAgendaItemUp( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + Model model + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + + meetingService.moveAgendaItemUp(meetingId, agendaItemId); + model.addAttribute("successMessage", "Dagordningspunkten flyttades upp."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); + } + + return "fragments/admin-meetings :: content"; + } + + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/move-down") + public String moveAgendaItemDown( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + Model model + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + + meetingService.moveAgendaItemDown(meetingId, agendaItemId); + model.addAttribute("successMessage", "Dagordningspunkten flyttades ner."); + + populateMeetingsPageAfterMeetingAction(model, meeting.getRegistry().getId(), meetingId); + + } catch (Exception exception) { + model.addAttribute("errorMessage", exception.getMessage()); + populateMeetingsPageAfterMeetingAction(model, null, null); + } + + return "fragments/admin-meetings :: content"; + } + + @DeleteMapping("/{meetingId}/agenda-items/{agendaItemId}") + public String removeAgendaItem( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + @RequestHeader(value = "HX-Request", required = false) String htmx, + Model model + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + + meetingService.removeAgendaItem(meetingId, agendaItemId); + 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); + } + + if (htmx != null) { + return "fragments/admin-meetings :: content"; + } + + return "admin/meetings"; + } + + @PostMapping("/{meetingId}/agenda-items/{agendaItemId}/documents") + public String addAgendaDocument( + @PathVariable Long meetingId, + @PathVariable Long agendaItemId, + @RequestParam Long caseFileId, + Model model + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + + meetingService.addDocumentToAgendaItem(meetingId, agendaItemId, caseFileId); + 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); + } + + 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 + ) { + try { + Meeting meeting = meetingService.getMeetingById(meetingId); + + meetingService.removeDocumentFromAgendaItem(meetingId, agendaItemId, documentId); + 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); + } + + return "fragments/admin-meetings :: content"; + } + + private void populateMeetingsPageAfterMeetingAction(Model model, Long registryId, Long meetingId) { + try { + populateMeetingsPage(model, registryId, meetingId); + } catch (MeetingNotFoundException | RegistryNotFoundException exception) { + populateMeetingsPage(model, null, null); + } + } + + 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) + : meetingService.getAllMeetings(); + + model.addAttribute("meetings", meetings); + + Meeting selectedMeeting = null; + 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); + + 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); + } +} 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..debbfd31 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingRepository.java @@ -0,0 +1,13 @@ +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); + List findAllByOrderByStartsAtDesc(); +} 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..e4a18e90 --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingService.java @@ -0,0 +1,376 @@ +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.*; +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 InvalidMeetingStateException("Registry-id måste anges."); + } + + if (title == null || title.isBlank()) { + throw new InvalidMeetingStateException("Titel måste anges."); + } + + if (startsAt == null) { + throw new InvalidMeetingStateException("Starttid måste anges."); + } + + if (endsAt != null && endsAt.isBefore(startsAt)) { + throw new InvalidMeetingStateException("Sluttid kan inte vara före starttid."); + } + + Registry registry = registryRepository.findById(registryId) + .orElseThrow(() -> new RegistryNotFoundException("Registry hittades inte.")); + + Meeting meeting = new Meeting( + registry, + title.trim(), + startsAt, + endsAt, + blankToNull(location), + MeetingStatus.PLANNED, + blankToNull(notes) + ); + + 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 InvalidMeetingStateException("Meeting-id måste anges."); + } + + if (title == null || title.isBlank()) { + throw new InvalidMeetingStateException("Titel måste anges."); + } + + if (startsAt == null) { + throw new InvalidMeetingStateException("Starttid måste anges."); + } + + if (endsAt != null && endsAt.isBefore(startsAt)) { + throw new InvalidMeetingStateException("Sluttid kan inte vara före starttid."); + } + + if (status == null) { + throw new InvalidMeetingStateException("Status måste anges."); + } + + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new MeetingNotFoundException("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 InvalidMeetingStateException("Meeting-id måste anges."); + } + + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new MeetingNotFoundException("Sammanträdet hittades inte.")); + + meetingRepository.delete(meeting); + } + + @Transactional(readOnly = true) + public List getMeetingsForRegistry(Long registryId) { + Registry registry = registryRepository.findById(registryId) + .orElseThrow(() -> new RegistryNotFoundException("Registry hittades inte.")); + + return meetingRepository.findByRegistryOrderByStartsAtDesc(registry); + } + + @Transactional(readOnly = true) + public Meeting getMeetingById(Long meetingId) { + return meetingRepository.findById(meetingId) + .orElseThrow(() -> new MeetingNotFoundException("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); + return meetingAgendaItemRepository.findByMeetingOrderByAgendaOrderAsc(meeting); + } + + public MeetingAgendaItem addCaseRecordToMeeting(Long meetingId, Long caseRecordId) { + if (meetingId == null) { + throw new InvalidMeetingStateException("Meeting-id måste anges."); + } + + if (caseRecordId == null) { + throw new InvalidMeetingStateException("Case record-id måste anges."); + } + + Meeting meeting = getMeetingById(meetingId); + + CaseRecord caseRecord = caseRecordRepository.findById(caseRecordId) + .orElseThrow(() -> new CaseRecordNotFoundException(caseRecordId)); + + validateCaseRecordBelongsToMeetingRegistry(meeting, caseRecord); + + if (meetingAgendaItemRepository.existsByMeetingAndCaseRecord(meeting, caseRecord)) { + throw new DuplicateMeetingAgendaItemException("Ä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); + } + + @Transactional + public void moveAgendaItemUp(Long meetingId, Long agendaItemId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("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 InvalidMeetingStateException("Kunde inte flytta upp dagordningspunkten.")); + + currentItem.setAgendaOrder(0); + meetingAgendaItemRepository.saveAndFlush(currentItem); + + previousItem.setAgendaOrder(currentOrder); + meetingAgendaItemRepository.saveAndFlush(previousItem); + + currentItem.setAgendaOrder(targetOrder); + meetingAgendaItemRepository.saveAndFlush(currentItem); + } + + @Transactional + public void moveAgendaItemDown(Long meetingId, Long agendaItemId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem currentItem = meetingAgendaItemRepository.findByIdAndMeeting(agendaItemId, meeting) + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("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(0); + meetingAgendaItemRepository.saveAndFlush(currentItem); + + nextItem.setAgendaOrder(currentOrder); + meetingAgendaItemRepository.saveAndFlush(nextItem); + + currentItem.setAgendaOrder(targetOrder); + meetingAgendaItemRepository.saveAndFlush(currentItem); + } + + public void removeAgendaItem(Long meetingId, Long agendaItemId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); + + if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { + throw new InvalidMeetingStateException("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 MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); + + return meetingAgendaDocumentRepository.findByAgendaItem(agendaItem); + } + + @Transactional(readOnly = true) + public List getAvailableCaseFilesForAgendaItem(Long agendaItemId) { + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); + + return caseFileRepository.findByCaseRecordIdOrderByUploadedAtDesc(agendaItem.getCaseRecord().getId()); + } + + public MeetingAgendaDocument addDocumentToAgendaItem(Long meetingId, Long agendaItemId, Long caseFileId) { + Meeting meeting = getMeetingById(meetingId); + + MeetingAgendaItem agendaItem = meetingAgendaItemRepository.findById(agendaItemId) + .orElseThrow(() -> new MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); + + if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { + throw new InvalidMeetingStateException("Dagordningspunkten tillhör inte detta sammanträde."); + } + + Long caseRecordId = agendaItem.getCaseRecord().getId(); + + CaseFile caseFile = caseFileRepository + .findByIdAndCaseRecordId(caseFileId, caseRecordId) + .orElseThrow(() -> new CaseFileNotFoundException(caseRecordId, caseFileId)); + + validateCaseFileBelongsToAgendaItemCaseRecord(agendaItem, caseFile); + + if (meetingAgendaDocumentRepository.existsByAgendaItemAndCaseFile(agendaItem, caseFile)) { + throw new DuplicateMeetingAgendaDocumentException("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 MeetingAgendaItemNotFoundException("Dagordningspunkten hittades inte.")); + + if (!agendaItem.getMeeting().getId().equals(meeting.getId())) { + throw new InvalidMeetingStateException("Dagordningspunkten tillhör inte detta sammanträde."); + } + + MeetingAgendaDocument document = meetingAgendaDocumentRepository.findById(documentId) + .orElseThrow(() -> new MeetingAgendaDocumentNotFoundException("Dokumentkopplingen hittades inte.")); + + if (!document.getAgendaItem().getId().equals(agendaItem.getId())) { + throw new InvalidMeetingStateException("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 InvalidMeetingStateException("Ärendet saknar diarium."); + } + + if (!caseRecord.getRegistry().getId().equals(meeting.getRegistry().getId())) { + 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 InvalidMeetingStateException("Handlingen saknar kopplat ärende."); + } + + if (!caseFile.getCaseRecord().getId().equals(agendaItem.getCaseRecord().getId())) { + throw new InvalidMeetingStateException("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(); + } +} 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..1f4a5caf --- /dev/null +++ b/src/main/java/backendlab/team4you/meeting/MeetingStatus.java @@ -0,0 +1,19 @@ +package backendlab.team4you.meeting; + +public enum MeetingStatus { + + 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/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 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 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..3b4bdbaf --- /dev/null +++ b/src/main/resources/static/css/meetings.css @@ -0,0 +1,213 @@ +.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; +} + +.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/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 new file mode 100644 index 00000000..d9dcee30 --- /dev/null +++ b/src/main/resources/templates/fragments/admin-meetings.html @@ -0,0 +1,346 @@ +
+ +

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

+ +

+ Diarium: + Kommunstyrelsen +

+ +

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

+ +

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

+ +

+ Plats: + Sessionssalen +

+ +

+ Status: + Planerad +

+ +

+ Anteckningar: + Anteckning +

+ +
+ +

Redigera sammanträde

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

Lägg till ärende

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

Dagordning

+ +
+

Inga ärenden tillagda ännu.

+
+ +
    +
  1. +
    +
    + 1. KS26-1 + Ärendetitel +
    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + +
    +
    Valda beslutsunderlag
    + +
    +

    Inga handlingar valda ännu.

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

    Det finns inga handlingar kopplade till ärendet.

    +
    + +
      +
    • +
      + budgetunderlag.pdf + +
      + + + + +
      +
      +
    • +
    +
    +
  2. +
+
+
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/fragments/admin-sidenav.html b/src/main/resources/templates/fragments/admin-sidenav.html index 002e0da4..194e4703 100644 --- a/src/main/resources/templates/fragments/admin-sidenav.html +++ b/src/main/resources/templates/fragments/admin-sidenav.html @@ -36,6 +36,16 @@ +
  • + + + Sammanträden + +
  • +
  • diff --git a/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java b/src/test/java/backendlab/team4you/casefile/CaseFileServiceTest.java index d42a5021..bc97dc8e 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; @@ -318,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); @@ -471,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) @@ -641,4 +648,27 @@ void listFileItemsForViewer_shouldShowOpenFileNormally() { assertThat(result.get(0).canDownload()).isTrue(); assertThat(result.get(0).confidential()).isFalse(); } + + @Test + @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); + 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 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..c195c590 --- /dev/null +++ b/src/test/java/backendlab/team4you/meeting/MeetingControllerTest.java @@ -0,0 +1,270 @@ +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") + .with(csrf()) + .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") + .with(csrf()) + .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") + .with(csrf()) + .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") + .with(csrf()) + .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); + } + } +} 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..f4c60f70 --- /dev/null +++ b/src/test/java/backendlab/team4you/meeting/MeetingServiceTest.java @@ -0,0 +1,361 @@ +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.*; +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(caseRecord.getId()).thenReturn(100L); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); + 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)); + + 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(caseRecord.getId()).thenReturn(100L); + + when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); + when(caseFileRepository.findByIdAndCaseRecordId(1000L, 100L)) + .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 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(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting)); + when(meetingAgendaItemRepository.findById(50L)).thenReturn(Optional.of(agendaItem)); + + assertThatThrownBy(() -> meetingService.addDocumentToAgendaItem(10L, 50L, 2000L)) + .isInstanceOf(CaseFileNotFoundException.class) + .hasMessage("File not found for case record. caseRecordId=100, fileId=2000"); + } + + @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); + } + } +}