diff --git a/pom.xml b/pom.xml index 8f37f26..0072ddc 100644 --- a/pom.xml +++ b/pom.xml @@ -104,6 +104,16 @@ spring-security-test test + + com.squareup.okhttp3 + okhttp + 5.1.0 + + + com.squareup.okhttp3 + logging-interceptor + 5.1.0 + diff --git a/src/main/java/org/example/team6backend/config/MinioConfig.java b/src/main/java/org/example/team6backend/config/MinioConfig.java new file mode 100644 index 0000000..144b04e --- /dev/null +++ b/src/main/java/org/example/team6backend/config/MinioConfig.java @@ -0,0 +1,24 @@ +package org.example.team6backend.config; + +import io.minio.MinioClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Value("${minio.url}") + private String url; + + @Value("${minio.access-key}") + private String accessKey; + + @Value("${minio.secret-key}") + private String secretKey; + + @Bean + public MinioClient minioClient() { + return MinioClient.builder().endpoint(url).credentials(accessKey, secretKey).build(); + } +} diff --git a/src/main/java/org/example/team6backend/document/controller/DocumentController.java b/src/main/java/org/example/team6backend/document/controller/DocumentController.java new file mode 100644 index 0000000..159893b --- /dev/null +++ b/src/main/java/org/example/team6backend/document/controller/DocumentController.java @@ -0,0 +1,67 @@ +package org.example.team6backend.document.controller; + +import org.example.team6backend.document.entity.Document; +import org.example.team6backend.document.service.DocumentService; +import org.example.team6backend.incident.entity.Incident; +import org.example.team6backend.incident.service.IncidentService; +import org.example.team6backend.security.CustomUserDetails; +import org.example.team6backend.user.entity.AppUser; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.List; + +@Controller +@RequestMapping("/documents") +public class DocumentController { + + private final DocumentService documentService; + private final IncidentService incidentService; + + public DocumentController(DocumentService documentService, IncidentService incidentService) { + this.documentService = documentService; + this.incidentService = incidentService; + } + + @PostMapping("/upload/{incidentId}") + public String uploadFile(@PathVariable Long incidentId, @RequestParam("files") List files, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + AppUser user = userDetails.getUser(); + Incident incident = incidentService.getById(incidentId, user); + + for (MultipartFile file : files) { + if (!file.isEmpty()) { + documentService.uploadFile(file, incident); + } + } + return "redirect:/incidents/" + incidentId; + } + + @GetMapping("/download/{incidentId}") + public ResponseEntity downloadFile(@PathVariable Long incidentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + AppUser user = userDetails.getUser(); + Incident incident = incidentService.getById(incidentId, user); + + List documents = documentService.getDocumentsByIncident(incident); + if (documents.isEmpty()) { + return ResponseEntity.notFound().build(); + } + Document document = documents.get(0); + + InputStream stream = documentService.downloadFile(document.getFileKey()); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + document.getFileName() + "\"") + .contentType(MediaType.parseMediaType(document.getContentType())).body(new InputStreamResource(stream)); + + } +} diff --git a/src/main/java/org/example/team6backend/document/entity/Document.java b/src/main/java/org/example/team6backend/document/entity/Document.java new file mode 100644 index 0000000..d7e3f43 --- /dev/null +++ b/src/main/java/org/example/team6backend/document/entity/Document.java @@ -0,0 +1,73 @@ +package org.example.team6backend.document.entity; + +import jakarta.persistence.*; +import org.example.team6backend.incident.entity.Incident; + +@Entity +public class Document { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "file_name") + private String fileName; + @Column(name = "content_type") + private String contentType; + @Column(name = "file_key") + private String fileKey; + @Column(name = "file_size") + private Long fileSize; + + @ManyToOne + @JoinColumn(name = "incident_id") + private Incident incident; + + public Long getId() { + return id; + } + + public String getFileName() { + return fileName; + } + + public String getContentType() { + return contentType; + } + + public String getFileKey() { + return fileKey; + } + + public Long getFileSize() { + return fileSize; + } + + public Incident getIncident() { + return incident; + } + + public void setId(Long id) { + this.id = id; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public void setFileKey(String fileKey) { + this.fileKey = fileKey; + } + + public void setFileSize(Long fileSize) { + this.fileSize = fileSize; + } + + public void setIncident(Incident incident) { + this.incident = incident; + } +} diff --git a/src/main/java/org/example/team6backend/document/repository/DocumentRepository.java b/src/main/java/org/example/team6backend/document/repository/DocumentRepository.java new file mode 100644 index 0000000..7833464 --- /dev/null +++ b/src/main/java/org/example/team6backend/document/repository/DocumentRepository.java @@ -0,0 +1,11 @@ +package org.example.team6backend.document.repository; + +import org.example.team6backend.document.entity.Document; +import org.example.team6backend.incident.entity.Incident; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DocumentRepository extends JpaRepository { + List findByIncident(Incident incident); +} diff --git a/src/main/java/org/example/team6backend/document/service/DocumentService.java b/src/main/java/org/example/team6backend/document/service/DocumentService.java new file mode 100644 index 0000000..dc18701 --- /dev/null +++ b/src/main/java/org/example/team6backend/document/service/DocumentService.java @@ -0,0 +1,68 @@ +package org.example.team6backend.document.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.team6backend.document.entity.Document; +import org.example.team6backend.document.repository.DocumentRepository; +import org.example.team6backend.incident.entity.Incident; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.io.InputStream; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DocumentService { + + private final S3Service s3Service; + private final DocumentRepository documentRepository; + + /** Upload file */ + public Document uploadFile(MultipartFile file, Incident incident) { + + String fileKey = UUID.randomUUID() + "_" + file.getOriginalFilename(); + boolean uploaded = false; + + try { + s3Service.uploadFile(fileKey, file); + uploaded = true; + + Document document = new Document(); + document.setFileName(file.getOriginalFilename()); + document.setContentType(file.getContentType()); + document.setFileKey(fileKey); + document.setFileSize(file.getSize()); + document.setIncident(incident); + + return documentRepository.save(document); + + } catch (Exception e) { + if (uploaded) { + try { + s3Service.deleteFile(fileKey); + } catch (Exception cleanupEx) { + log.warn("Failed to cleanup S3 file: {}", fileKey, cleanupEx); + } + } + throw new RuntimeException("File upload failed", e); + } + } + + /** Download file */ + public InputStream downloadFile(String objectKey) { + return s3Service.downloadFile(objectKey); + } + + /** Delete file */ + public void deleteFile(Document document) { + s3Service.deleteFile(document.getFileKey()); + documentRepository.delete(document); + } + + /** Fetch all files connected to one incident */ + public List getDocumentsByIncident(Incident incidentId) { + return documentRepository.findByIncident(incidentId); + } +} diff --git a/src/main/java/org/example/team6backend/document/service/S3Service.java b/src/main/java/org/example/team6backend/document/service/S3Service.java new file mode 100644 index 0000000..c1ed209 --- /dev/null +++ b/src/main/java/org/example/team6backend/document/service/S3Service.java @@ -0,0 +1,61 @@ +package org.example.team6backend.document.service; + +import io.minio.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private final MinioClient minioClient; + + @Value("${minio.bucket}") + private String bucketName; + + @PostConstruct + public void init() { + try { + boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); + + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); + } + } catch (Exception e) { + throw new RuntimeException("Could not initialize minio service", e); + } + } + + /** Upload file to MinIO */ + public void uploadFile(String fileKey, MultipartFile file) { + try { + minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(fileKey) + .stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build()); + } catch (Exception e) { + throw new RuntimeException("Failed to upload file " + fileKey, e); + } + } + + /** Fetch file from MinIO */ + public InputStream downloadFile(String fileKey) { + try { + return minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(fileKey).build()); + } catch (Exception e) { + throw new RuntimeException("Failed to download file " + fileKey, e); + } + } + + /** Delete file from MinIO */ + public void deleteFile(String fileKey) { + try { + minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(fileKey).build()); + } catch (Exception e) { + throw new RuntimeException("Failed to delete file " + fileKey, e); + } + } +} diff --git a/src/main/java/org/example/team6backend/incident/controller/IncidentController.java b/src/main/java/org/example/team6backend/incident/controller/IncidentController.java index fc29768..0db24db 100644 --- a/src/main/java/org/example/team6backend/incident/controller/IncidentController.java +++ b/src/main/java/org/example/team6backend/incident/controller/IncidentController.java @@ -38,13 +38,12 @@ public IncidentController(IncidentService incidentService, UserService userServi /** Create new incident */ @PostMapping @PreAuthorize("hasAnyRole('RESIDENT', 'ADMIN')") - public IncidentResponse createIncident(@RequestBody @Valid IncidentRequest incidentRequest) { - Incident incident = new Incident(); - incident.setSubject(incidentRequest.getSubject()); - incident.setDescription(incidentRequest.getDescription()); - incident.setIncidentCategory(incidentRequest.getIncidentCategory()); + public IncidentResponse createIncident(@RequestBody @Valid IncidentRequest incidentRequest, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + AppUser user = customUserDetails.getUser(); + + Incident saved = incidentService.createIncident(incidentRequest, null, user); - Incident saved = incidentService.createIncident(incident); return IncidentResponse.fromEntity(saved); } diff --git a/src/main/java/org/example/team6backend/incident/entity/Incident.java b/src/main/java/org/example/team6backend/incident/entity/Incident.java index 959e9f0..ec53efd 100644 --- a/src/main/java/org/example/team6backend/incident/entity/Incident.java +++ b/src/main/java/org/example/team6backend/incident/entity/Incident.java @@ -1,9 +1,10 @@ package org.example.team6backend.incident.entity; import jakarta.persistence.*; +import org.example.team6backend.document.entity.Document; import org.example.team6backend.user.entity.AppUser; - import java.time.LocalDateTime; +import java.util.List; @Entity public class Incident { @@ -40,6 +41,9 @@ public class Incident { @Column(name = "updated_at") private LocalDateTime updatedAt; + @OneToMany(mappedBy = "incident", cascade = CascadeType.ALL, orphanRemoval = true) + private List documents; + @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); @@ -91,6 +95,10 @@ public LocalDateTime getUpdatedAt() { return updatedAt; } + public List getDocuments() { + return documents; + } + public void setId(Long id) { this.id = id; } @@ -130,4 +138,8 @@ public void setCreatedAt(LocalDateTime createdAt) { public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public void setDocuments(List documents) { + this.documents = documents; + } } diff --git a/src/main/java/org/example/team6backend/incident/service/IncidentService.java b/src/main/java/org/example/team6backend/incident/service/IncidentService.java index 730c824..653a438 100644 --- a/src/main/java/org/example/team6backend/incident/service/IncidentService.java +++ b/src/main/java/org/example/team6backend/incident/service/IncidentService.java @@ -2,9 +2,12 @@ import lombok.extern.slf4j.Slf4j; import org.example.team6backend.activity.service.ActivityLogService; +import org.example.team6backend.document.entity.Document; +import org.example.team6backend.document.service.DocumentService; +import org.example.team6backend.document.service.S3Service; import org.example.team6backend.exception.ResourceNotFoundException; +import org.example.team6backend.incident.dto.IncidentRequest; import org.example.team6backend.notification.service.NotificationService; -import org.example.team6backend.security.CustomUserDetails; import org.example.team6backend.user.entity.AppUser; import org.example.team6backend.incident.entity.Incident; import org.example.team6backend.incident.entity.IncidentStatus; @@ -16,13 +19,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; - import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Service @Slf4j @@ -30,15 +33,20 @@ public class IncidentService { private final IncidentRepository incidentRepository; private final ActivityLogService activityLogService; + private final DocumentService documentService; private final AppUserRepository userRepository; private final NotificationService notificationService; + private final S3Service s3Service; public IncidentService(IncidentRepository incidentRepository, ActivityLogService activityLogService, - AppUserRepository userRepository, NotificationService notificationService) { + DocumentService documentService, AppUserRepository userRepository, NotificationService notificationService, + S3Service s3Service) { this.incidentRepository = incidentRepository; this.activityLogService = activityLogService; + this.documentService = documentService; this.userRepository = userRepository; this.notificationService = notificationService; + this.s3Service = s3Service; } /** Help-method for sorting **/ @@ -50,21 +58,46 @@ private Pageable withDefaultSort(Pageable pageable) { } /** Create incident **/ - public Incident createIncident(Incident incident) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal(); - AppUser appUser = userDetails.getUser(); - - incident.setCreatedBy(appUser); - incident.setIncidentStatus(IncidentStatus.OPEN); - incident.setCreatedAt(LocalDateTime.now()); - incident.setUpdatedAt(LocalDateTime.now()); - - Incident savedIncident = incidentRepository.save(incident); - - activityLogService.log("INCIDENT_CREATED", appUser.getName() + " created the incident", savedIncident, appUser); + @Transactional + public Incident createIncident(IncidentRequest incidentRequest, List files, AppUser user) { + + List uploadedKeys = new ArrayList<>(); + + try { + Incident incident = new Incident(); + incident.setSubject(incidentRequest.getSubject()); + incident.setDescription(incidentRequest.getDescription()); + incident.setIncidentCategory(incidentRequest.getIncidentCategory()); + incident.setCreatedBy(user); + incident.setIncidentStatus(IncidentStatus.OPEN); + incident.setCreatedAt(LocalDateTime.now()); + incident.setUpdatedAt(LocalDateTime.now()); + + Incident savedIncident = incidentRepository.save(incident); + + if (files != null) { + for (MultipartFile file : files) { + if (!file.isEmpty()) { + Document savedDocument = documentService.uploadFile(file, savedIncident); + uploadedKeys.add(savedDocument.getFileKey()); + } + } + } + activityLogService.log("INCIDENT_CREATED", user.getName() + " created incident.", savedIncident, user); + + return savedIncident; + + } catch (Exception e) { + for (String key : uploadedKeys) { + try { + s3Service.deleteFile(key); + } catch (Exception cleanupEx) { + log.warn("Failed rollback cleanup for fileKey: {}", key, cleanupEx); + } + } + throw e; + } - return savedIncident; } /** Find all incidents (Admin) **/ @@ -81,6 +114,7 @@ public Page findByCreatedBy(AppUser user, Pageable pageable) { public Page findByAssignedTo(AppUser user, Pageable pageable) { return incidentRepository.findByAssignedTo(user, withDefaultSort(pageable)); } + public Incident getById(Long id, AppUser user) { Incident incident = incidentRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Not found")); @@ -94,6 +128,18 @@ public Incident getById(Long id, AppUser user) { } return incident; } + @Transactional + public void deleteIncident(Incident incident) { + + for (Document document : incident.getDocuments()) { + try { + s3Service.deleteFile(document.getFileKey()); + } catch (Exception e) { + log.warn("Failed to delete file from S3: " + document.getFileKey(), e); + } + } + incidentRepository.delete(incident); + } @Transactional public Incident assignIncidentToHandler(Long incidentId, String handlerId, AppUser currentUser) { diff --git a/src/main/java/org/example/team6backend/page/PageController.java b/src/main/java/org/example/team6backend/page/PageController.java index b1c9728..5636378 100644 --- a/src/main/java/org/example/team6backend/page/PageController.java +++ b/src/main/java/org/example/team6backend/page/PageController.java @@ -1,6 +1,7 @@ package org.example.team6backend.page; import jakarta.validation.Valid; +import org.example.team6backend.document.service.DocumentService; import org.example.team6backend.incident.dto.IncidentRequest; import org.example.team6backend.incident.entity.Incident; import org.example.team6backend.incident.service.IncidentService; @@ -18,15 +19,20 @@ import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @Controller public class PageController { private final UserService userService; private final IncidentService incidentService; + private final DocumentService documentService; - public PageController(UserService userService, IncidentService incidentService) { + public PageController(UserService userService, IncidentService incidentService, DocumentService documentService) { this.userService = userService; this.incidentService = incidentService; + this.documentService = documentService; } @GetMapping("/") @@ -85,29 +91,22 @@ public String createIncident(@AuthenticationPrincipal CustomUserDetails userDeta @PreAuthorize("hasAnyRole('RESIDENT', 'ADMIN')") @PostMapping("/create-incident") public String submitIncident(@AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @ModelAttribute IncidentRequest incidentRequest, BindingResult bindingResult, Model model, + @Valid @ModelAttribute IncidentRequest incidentRequest, BindingResult bindingResult, + @RequestParam(value = "files", required = false) List files, Model model, HttpServletRequest request) { + AppUser user = userDetails.getUser(); - String role = user.getRole().name(); if (bindingResult.hasErrors()) { - model.addAttribute("role", role); - model.addAttribute("user", user); CsrfToken csrf = (CsrfToken) request.getAttribute("_csrf"); model.addAttribute("_csrf", csrf); + model.addAttribute("role", user.getRole().name()); + model.addAttribute("user", user); return "createincident"; } - Incident incident = new Incident(); - incident.setSubject(incidentRequest.getSubject()); - incident.setDescription(incidentRequest.getDescription()); - incident.setIncidentCategory(incidentRequest.getIncidentCategory()); - incident.setCreatedBy(user); - - Incident saved = incidentService.createIncident(incident); + Incident saved = incidentService.createIncident(incidentRequest, files, user); - model.addAttribute("success", "Incident created successfully!"); - model.addAttribute("incidentRequest", incidentRequest); return "redirect:/incidents/" + saved.getId(); } diff --git a/src/main/resources/db/migration/V8__create_document_table.sql b/src/main/resources/db/migration/V8__create_document_table.sql new file mode 100644 index 0000000..f9c45aa --- /dev/null +++ b/src/main/resources/db/migration/V8__create_document_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE document( + id BIGSERIAL PRIMARY KEY, + file_name VARCHAR(255), + content_type VARCHAR(255), + file_key VARCHAR(255) NOT NULL, + file_size BIGINT, + + incident_id BIGINT NOT NULL, + + CONSTRAINT fk_document_incident + FOREIGN KEY (incident_id) + REFERENCES incident(id) +); \ No newline at end of file diff --git a/src/main/resources/templates/createincident.html b/src/main/resources/templates/createincident.html index 11254cd..ce21bbb 100644 --- a/src/main/resources/templates/createincident.html +++ b/src/main/resources/templates/createincident.html @@ -68,7 +68,11 @@

Report Incident

-
+ + @@ -101,6 +105,9 @@

Report Incident

+ + +
diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 068e864..6c782a1 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -607,7 +607,7 @@

Account pending approval

container.innerHTML = '

No incidents yet.

'; } else { container.innerHTML = incidents.map(i => ` -
+
${escapeHtml(i.subject)}
${new Date(i.createdAt).toLocaleDateString()}
diff --git a/src/main/resources/templates/incidents.html b/src/main/resources/templates/incidents.html index 7175ee9..2b88966 100644 --- a/src/main/resources/templates/incidents.html +++ b/src/main/resources/templates/incidents.html @@ -170,7 +170,7 @@

Active incidents

} function viewIncident(id) { - window.location.href = `/incidents/${id}`; + window.location.href = `/incident/${id}`; } function escapeHtml(str) {