DocumentService.java
package com.edtech.service;
import com.edtech.dto.DocumentResponseDto;
import com.edtech.model.AuditAction;
import com.edtech.model.Document;
import com.edtech.model.DocumentStatus;
import com.edtech.model.Project;
import com.edtech.model.ProjectMember;
import com.edtech.model.ProjectRole;
import com.edtech.model.User;
import com.edtech.repository.DocumentCommentRepository;
import com.edtech.repository.DocumentRepository;
import com.edtech.repository.ProjectMemberRepository;
import com.edtech.repository.ProjectRepository;
import com.edtech.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.apache.tika.Tika;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
/** Documentacao para DocumentService. */
@Service
public class DocumentService {
private static final String MIME_PDF = "application/pdf";
private static final String MIME_CSV = "text/csv";
private static final String MIME_JSON = "application/json";
private static final Map<String, String> ALLOWED_MIME_BY_EXTENSION =
Map.of(".pdf", MIME_PDF, ".csv", MIME_CSV, ".json", MIME_JSON);
private static final Set<String> CSV_COMPATIBLE_DETECTED_TYPES =
Set.of(MIME_CSV, "text/plain", "application/csv", "application/vnd.ms-excel");
private static final Set<String> JSON_COMPATIBLE_DETECTED_TYPES = Set.of(MIME_JSON, "text/plain");
private final DocumentRepository documentRepository;
private final ProjectRepository projectRepository;
private final UserRepository userRepository;
private final ProjectMemberRepository projectMemberRepository;
private final DocumentCommentRepository documentCommentRepository;
private final AuditLogService auditLogService;
private final StorageService storageService;
private final NotificationService notificationService;
private final Tika tika = new Tika();
private final ObjectMapper objectMapper = new ObjectMapper();
/** Documentacao. */
public DocumentService(
DocumentRepository documentRepository,
ProjectRepository projectRepository,
UserRepository userRepository,
ProjectMemberRepository projectMemberRepository,
DocumentCommentRepository documentCommentRepository,
AuditLogService auditLogService,
StorageService storageService,
NotificationService notificationService) {
this.documentRepository = documentRepository;
this.projectRepository = projectRepository;
this.userRepository = userRepository;
this.projectMemberRepository = projectMemberRepository;
this.documentCommentRepository = documentCommentRepository;
this.auditLogService = auditLogService;
this.storageService = storageService;
this.notificationService = notificationService;
}
/** Documentacao. */
@Transactional
public DocumentResponseDto uploadDocument(
MultipartFile file, String title, UUID projectId, UUID authorId) {
User author =
userRepository
.findById(authorId)
.orElseThrow(() -> new RuntimeException("Author not found"));
Project project =
projectRepository
.findById(projectId)
.orElseThrow(() -> new RuntimeException("Project not found"));
projectMemberRepository
.findByProjectIdAndUserId(projectId, authorId)
.orElseThrow(() -> new RuntimeException("Author is not a member of the project"));
try {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new IllegalArgumentException("Filename cannot be null");
}
String contentType = validateAllowedFile(file, originalFilename);
String fileKey = UUID.randomUUID() + "_" + originalFilename;
storageService.uploadFile(file, fileKey, contentType);
Document document = new Document();
document.setTitle(title);
document.setFileUrl(fileKey);
document.setStatus(DocumentStatus.DRAFT);
document.setAuthor(author);
document.setProject(project);
document = documentRepository.save(document);
auditLogService.logDocumentAction(
authorId,
AuditAction.UPLOAD_SUCCESS,
document.getId(),
"Documento anexado no Cloud Storage: " + title);
DocumentResponseDto responseDto = mapToDto(document);
notificationService.sendToTopic(
"/topic/projects/" + projectId,
Map.of("type", "DOCUMENT_UPLOADED", "document", responseDto));
return responseDto;
} catch (IOException e) {
throw new RuntimeException("Falha ao analisar o conteudo do arquivo", e);
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Failed to upload file to Cloud Storage: " + e.getMessage());
}
}
/** Documentacao para o metodo getPresignedUrl. */
public String getPresignedUrl(UUID documentId, UUID userId) {
Document document =
documentRepository
.findById(documentId)
.orElseThrow(() -> new RuntimeException("Document not found"));
projectMemberRepository
.findByProjectIdAndUserId(document.getProject().getId(), userId)
.orElseThrow(
() -> new RuntimeException("Access denied: You are not a member of this project"));
String presignedUrl;
try {
presignedUrl = storageService.getPresignedUrl(document.getFileUrl());
} catch (Exception e) {
throw new RuntimeException("Failed to generate presigned URL", e);
}
auditLogService.logDocumentAction(
userId,
AuditAction.DOWNLOAD,
documentId,
"Gerada URL presigned para download: " + document.getTitle());
return presignedUrl;
}
/** Documentacao. */
public Page<DocumentResponseDto> listDocumentsByUser(
UUID userId, UUID projectId, String title, DocumentStatus status, Pageable pageable) {
return documentRepository
.findDocumentsByUserIdAndFilters(userId, projectId, title, status, pageable)
.map(this::mapToDto);
}
/** Documentacao. */
@Transactional
public void deleteDocument(UUID documentId, UUID userId) {
Document document =
documentRepository
.findById(documentId)
.orElseThrow(() -> new RuntimeException("Document not found"));
if (!document.getAuthor().getId().equals(userId)) {
throw new RuntimeException("Only the author can delete this document");
}
if (document.getStatus() != DocumentStatus.DRAFT) {
throw new RuntimeException("Only DRAFT documents can be deleted");
}
try {
storageService.deleteFile(document.getFileUrl());
} catch (Exception e) {
throw new RuntimeException("Erro ao excluir arquivo fisico: " + e.getMessage());
}
documentRepository.delete(document);
auditLogService.logDocumentAction(
userId,
AuditAction.DELETE_DOCUMENT,
documentId,
"Documento excluido: " + document.getTitle());
}
private String validateAllowedFile(MultipartFile file, String originalFilename)
throws IOException {
String extension = extractExtension(originalFilename);
String expectedMimeType = ALLOWED_MIME_BY_EXTENSION.get(extension);
if (expectedMimeType == null) {
throw new IllegalArgumentException(
"Tipo de arquivo nao permitido. Formatos aceitos: PDF, CSV e JSON.");
}
String declaredType = normalizeMimeType(file.getContentType());
if (!expectedMimeType.equals(declaredType)) {
throw new IllegalArgumentException(
"Content-Type nao permitido para "
+ extension
+ ". Esperado: "
+ expectedMimeType
+ ". Recebido: "
+ declaredType);
}
String detectedType = normalizeMimeType(tika.detect(file.getInputStream(), originalFilename));
if (!isDetectedTypeCompatible(expectedMimeType, detectedType)) {
throw new IllegalArgumentException(
"Conteudo do arquivo nao corresponde ao tipo permitido. Tipo detectado: " + detectedType);
}
if (MIME_JSON.equals(expectedMimeType)) {
validateJsonContent(file);
}
return expectedMimeType;
}
private String extractExtension(String filename) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex < 0 || lastDotIndex == filename.length() - 1) {
return "";
}
return filename.substring(lastDotIndex).toLowerCase(Locale.ROOT);
}
private boolean isDetectedTypeCompatible(String expectedMimeType, String detectedType) {
if (MIME_CSV.equals(expectedMimeType)) {
return CSV_COMPATIBLE_DETECTED_TYPES.contains(detectedType);
}
if (MIME_JSON.equals(expectedMimeType)) {
return JSON_COMPATIBLE_DETECTED_TYPES.contains(detectedType);
}
return expectedMimeType.equals(detectedType);
}
private String normalizeMimeType(String mimeType) {
if (mimeType == null || mimeType.isBlank()) {
return "";
}
return mimeType.split(";")[0].trim().toLowerCase(Locale.ROOT);
}
private void validateJsonContent(MultipartFile file) throws IOException {
objectMapper.readTree(file.getInputStream());
}
private DocumentResponseDto mapToDto(Document document) {
DocumentResponseDto dto = new DocumentResponseDto();
dto.setId(document.getId());
dto.setTitle(document.getTitle());
dto.setFileUrl(document.getFileUrl());
dto.setStatus(document.getStatus());
dto.setAuthorId(document.getAuthor().getId());
dto.setProjectId(document.getProject().getId());
dto.setCreatedAt(document.getCreatedAt());
dto.setFeedback(document.getFeedback());
dto.setStarred(document.isStarred());
return dto;
}
/** Javadoc. */
@Transactional(readOnly = true)
public java.util.List<com.edtech.dto.CommentResponseDto> getComments(
UUID documentId, UUID userId) {
Document document =
documentRepository
.findById(documentId)
.orElseThrow(() -> new RuntimeException("Document not found"));
projectMemberRepository
.findByProjectIdAndUserId(document.getProject().getId(), userId)
.orElseThrow(() -> new RuntimeException("Access denied"));
return documentCommentRepository.findByDocumentIdOrderByCreatedAtAsc(documentId).stream()
.map(
c -> {
com.edtech.dto.CommentResponseDto dto = new com.edtech.dto.CommentResponseDto();
dto.setId(c.getId());
dto.setContent(c.getContent());
dto.setCreatedAt(c.getCreatedAt());
dto.setAuthorId(c.getAuthor().getId());
dto.setAuthorName(c.getAuthor().getName());
return dto;
})
.collect(java.util.stream.Collectors.toList());
}
/** Javadoc. */
@Transactional
public com.edtech.dto.CommentResponseDto addComment(
UUID documentId, UUID userId, String content) {
Document document =
documentRepository
.findById(documentId)
.orElseThrow(() -> new RuntimeException("Document not found"));
User user =
userRepository.findById(userId).orElseThrow(() -> new RuntimeException("User not found"));
projectMemberRepository
.findByProjectIdAndUserId(document.getProject().getId(), userId)
.orElseThrow(() -> new RuntimeException("Access denied"));
com.edtech.model.DocumentComment comment = new com.edtech.model.DocumentComment();
comment.setDocument(document);
comment.setAuthor(user);
comment.setContent(content);
comment = documentCommentRepository.save(comment);
auditLogService.logDocumentAction(
userId, AuditAction.REVIEW_DOCUMENT, documentId, "Adicionou um comentário ao documento.");
com.edtech.dto.CommentResponseDto dto = new com.edtech.dto.CommentResponseDto();
dto.setId(comment.getId());
dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt());
dto.setAuthorId(comment.getAuthor().getId());
dto.setAuthorName(comment.getAuthor().getName());
notificationService.sendToTopic(
"/topic/projects/" + document.getProject().getId(),
Map.of("type", "NEW_COMMENT", "documentId", documentId, "comment", dto));
return dto;
}
/** Documentacao. */
@Transactional
public DocumentResponseDto reviewDocument(
UUID documentId, UUID reviewerId, DocumentStatus newStatus, String feedback) {
if (newStatus != DocumentStatus.APPROVED && newStatus != DocumentStatus.REJECTED) {
throw new IllegalArgumentException(
"Status invalido. Apenas APPROVED ou REJECTED sao permitidos na revisao.");
}
Document document =
documentRepository
.findById(documentId)
.orElseThrow(() -> new RuntimeException("Document not found"));
ProjectMember member =
projectMemberRepository
.findByProjectIdAndUserId(document.getProject().getId(), reviewerId)
.orElseThrow(
() -> new RuntimeException("Acess denied: You are not a member of this project"));
if (member.getRole() != ProjectRole.ADVISOR) {
throw new RuntimeException("Acess denied: Only an ADVISOR can review documents");
}
if (document.getStatus() != DocumentStatus.PENDING_REVIEW) {
throw new RuntimeException("Document is not pending review");
}
document.setStatus(newStatus);
document.setFeedback(feedback);
Document savedDocument = documentRepository.save(document);
AuditAction action =
(newStatus == DocumentStatus.APPROVED)
? AuditAction.DOCUMENT_APPROVED
: AuditAction.DOCUMENT_REJECTED;
String details =
"Status alterado para "
+ newStatus
+ ".Feedback: "
+ (feedback != null && !feedback.trim().isEmpty() ? feedback : "Sem feedback");
auditLogService.logDocumentAction(reviewerId, action, documentId, details);
DocumentResponseDto responseDto = mapToDto(savedDocument);
notificationService.sendToTopic(
"/topic/projects/" + document.getProject().getId(),
Map.of("type", "DOCUMENT_REVIEWED", "document", responseDto));
return responseDto;
}
/** Javadoc. */
@Transactional
public DocumentResponseDto toggleStar(UUID documentId, UUID userId) {
Document document =
documentRepository
.findById(documentId)
.orElseThrow(() -> new RuntimeException("Document not found"));
projectMemberRepository
.findByProjectIdAndUserId(document.getProject().getId(), userId)
.orElseThrow(
() -> new RuntimeException("Access denied: You are not a member of this project"));
document.setStarred(!document.isStarred());
Document savedDocument = documentRepository.save(document);
return mapToDto(savedDocument);
}
}