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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package backend.fullstack.document.api;

import java.util.List;

import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import backend.fullstack.config.ApiResponse;
import backend.fullstack.config.JwtPrincipal;
import backend.fullstack.document.api.dto.DocumentResponse;
import backend.fullstack.document.application.DocumentService;
import backend.fullstack.document.domain.Document;
import backend.fullstack.document.domain.DocumentCategory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;

/**
* REST controller for document storage management.
* Provides upload, download, list, and delete operations for organizational documents
* such as policies, training materials, and certifications.
*/
@RestController
@RequestMapping("/api/documents")
@Tag(name = "Documents", description = "Document storage for policies, training materials, and certifications")
@SecurityRequirement(name = "Bearer Auth")
public class DocumentController {

private final DocumentService documentService;

public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}

/**
* Lists all documents for the authenticated user's organization.
*
* @param principal the authenticated user
* @param category optional category filter
* @return list of document metadata
*/
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(
summary = "List documents",
description = "Returns all documents for the organization, optionally filtered by category"
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Documents retrieved"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized")
})
public ResponseEntity<ApiResponse<List<DocumentResponse>>> listDocuments(
@AuthenticationPrincipal JwtPrincipal principal,
@RequestParam(required = false) @Parameter(description = "Filter by document category") DocumentCategory category
) {
List<DocumentResponse> documents = documentService
.listDocuments(principal.organizationId(), category)
.stream()
.map(DocumentResponse::from)
.toList();
return ResponseEntity.ok(ApiResponse.success("Documents retrieved", documents));
}

/**
* Downloads a document by its ID.
*
* @param principal the authenticated user
* @param id the document ID
* @return the file content as a byte stream
*/
@GetMapping("/{id}/download")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(
summary = "Download a document",
description = "Downloads the file content of a specific document"
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "File downloaded"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized")
})
public ResponseEntity<byte[]> downloadDocument(
@AuthenticationPrincipal JwtPrincipal principal,
@PathVariable Long id
) {
Document doc = documentService.getDocument(id, principal.organizationId());

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(doc.getContentType()));
headers.setContentDisposition(
ContentDisposition.attachment().filename(doc.getFileName()).build()
);
headers.setContentLength(doc.getFileSize());

return ResponseEntity.ok().headers(headers).body(doc.getFileData());
}

/**
* Gets document metadata by its ID (without file content).
*
* @param principal the authenticated user
* @param id the document ID
* @return the document metadata
*/
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(
summary = "Get document metadata",
description = "Returns metadata for a specific document without the file content"
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Document metadata retrieved"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized")
})
public ResponseEntity<ApiResponse<DocumentResponse>> getDocument(
@AuthenticationPrincipal JwtPrincipal principal,
@PathVariable Long id
) {
Document doc = documentService.getDocument(id, principal.organizationId());
return ResponseEntity.ok(ApiResponse.success("Document retrieved", DocumentResponse.from(doc)));
}

/**
* Uploads a new document.
*
* @param principal the authenticated user
* @param file the file to upload (max 10 MB)
* @param title document title
* @param description optional description
* @param category document category
* @return the created document metadata
*/
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')")
@Operation(
summary = "Upload a document",
description = "Uploads a new document (max 10 MB). Accepts policies, training materials, "
+ "certifications, inspection reports, and other documents."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Document uploaded"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file or parameters"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden — STAFF cannot upload")
})
public ResponseEntity<ApiResponse<DocumentResponse>> uploadDocument(
@AuthenticationPrincipal JwtPrincipal principal,
@RequestParam("file") MultipartFile file,
@RequestParam("title") String title,
@RequestParam(value = "description", required = false) String description,
@RequestParam("category") DocumentCategory category
) {
Document doc = documentService.uploadDocument(
principal.organizationId(),
principal.userId(),
title,
description,
category,
file
);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Document uploaded", DocumentResponse.from(doc)));
}

/**
* Deletes a document by its ID.
*
* @param principal the authenticated user
* @param id the document ID
* @return success message
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','SUPERVISOR')")
@Operation(
summary = "Delete a document",
description = "Permanently deletes a document. Only ADMIN and SUPERVISOR can delete."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Document deleted"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden")
})
public ResponseEntity<ApiResponse<Void>> deleteDocument(
@AuthenticationPrincipal JwtPrincipal principal,
@PathVariable Long id
) {
documentService.deleteDocument(id, principal.organizationId());
return ResponseEntity.ok(ApiResponse.success("Document deleted", null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package backend.fullstack.document.api.dto;

import java.time.LocalDateTime;

import backend.fullstack.document.domain.Document;
import backend.fullstack.document.domain.DocumentCategory;

/**
* Response DTO for document metadata (excludes file content).
*/
public record DocumentResponse(
Long id,
String title,
String description,
DocumentCategory category,
String fileName,
String contentType,
Long fileSize,
String uploadedBy,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
/**
* Maps a {@link Document} entity to a response DTO.
*
* @param doc the document entity
* @return the response DTO
*/
public static DocumentResponse from(Document doc) {
return new DocumentResponse(
doc.getId(),
doc.getTitle(),
doc.getDescription(),
doc.getCategory(),
doc.getFileName(),
doc.getContentType(),
doc.getFileSize(),
doc.getUploadedByName(),
doc.getCreatedAt(),
doc.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package backend.fullstack.document.application;

import java.io.IOException;
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import backend.fullstack.document.domain.Document;
import backend.fullstack.document.domain.DocumentCategory;
import backend.fullstack.document.infrastructure.DocumentRepository;
import backend.fullstack.organization.Organization;
import backend.fullstack.organization.OrganizationRepository;
import backend.fullstack.user.User;
import backend.fullstack.user.UserRepository;

/**
* Service for managing document uploads, retrieval, and deletion.
*/
@Service
@Transactional(readOnly = true)
public class DocumentService {

/** Maximum allowed file size: 10 MB */
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;

private final DocumentRepository documentRepository;
private final OrganizationRepository organizationRepository;
private final UserRepository userRepository;

public DocumentService(
DocumentRepository documentRepository,
OrganizationRepository organizationRepository,
UserRepository userRepository
) {
this.documentRepository = documentRepository;
this.organizationRepository = organizationRepository;
this.userRepository = userRepository;
}

/**
* Lists all documents for an organization, optionally filtered by category.
*
* @param organizationId the organization scope
* @param category optional category filter
* @return list of documents (without file content)
*/
public List<Document> listDocuments(Long organizationId, DocumentCategory category) {
if (category != null) {
return documentRepository.findByOrganization_IdAndCategoryOrderByCreatedAtDesc(
organizationId, category);
}
return documentRepository.findByOrganization_IdOrderByCreatedAtDesc(organizationId);
}

/**
* Retrieves a single document by ID, scoped to the organization.
*
* @param id the document ID
* @param organizationId the organization scope
* @return the document entity including file data
* @throws IllegalArgumentException if the document is not found
*/
public Document getDocument(Long id, Long organizationId) {
return documentRepository.findByIdAndOrganization_Id(id, organizationId)
.orElseThrow(() -> new IllegalArgumentException("Document not found: " + id));
}

/**
* Uploads a new document.
*
* @param organizationId the organization scope
* @param userId the uploading user's ID
* @param title document title
* @param description optional description
* @param category document category
* @param file the uploaded file
* @return the persisted document entity
*/
@Transactional
public Document uploadDocument(Long organizationId, Long userId,
String title, String description,
DocumentCategory category, MultipartFile file) {
validateFile(file);

Organization org = organizationRepository.findById(organizationId)
.orElseThrow(() -> new IllegalArgumentException("Organization not found: " + organizationId));

User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));

byte[] fileData;
try {
fileData = file.getBytes();
} catch (IOException e) {
throw new IllegalStateException("Failed to read uploaded file", e);
}

Document document = Document.builder()
.organization(org)
.uploadedBy(user)
.title(title)
.description(description)
.category(category)
.fileName(file.getOriginalFilename())
.contentType(file.getContentType())
.fileSize(file.getSize())
.fileData(fileData)
.build();

return documentRepository.save(document);
}

/**
* Deletes a document by ID, scoped to the organization.
*
* @param id the document ID
* @param organizationId the organization scope
* @throws IllegalArgumentException if the document is not found
*/
@Transactional
public void deleteDocument(Long id, Long organizationId) {
Document document = documentRepository.findByIdAndOrganization_Id(id, organizationId)
.orElseThrow(() -> new IllegalArgumentException("Document not found: " + id));
documentRepository.delete(document);
}

private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("File must not be empty");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException(
"File size exceeds maximum allowed size of " + (MAX_FILE_SIZE / 1024 / 1024) + " MB");
}
}
}
Loading
Loading