Skip to content

Commit 8d159fe

Browse files
Merge pull request #35 from Stcwal/backend/document-storage
Add data document storage
2 parents c8cb62d + f71595a commit 8d159fe

9 files changed

Lines changed: 751 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package backend.fullstack.document.api;
2+
3+
import java.util.List;
4+
5+
import org.springframework.http.ContentDisposition;
6+
import org.springframework.http.HttpHeaders;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.http.MediaType;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.security.access.prepost.PreAuthorize;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.DeleteMapping;
13+
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.PathVariable;
15+
import org.springframework.web.bind.annotation.PostMapping;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RequestParam;
18+
import org.springframework.web.bind.annotation.RestController;
19+
import org.springframework.web.multipart.MultipartFile;
20+
21+
import backend.fullstack.config.ApiResponse;
22+
import backend.fullstack.config.JwtPrincipal;
23+
import backend.fullstack.document.api.dto.DocumentResponse;
24+
import backend.fullstack.document.application.DocumentService;
25+
import backend.fullstack.document.domain.Document;
26+
import backend.fullstack.document.domain.DocumentCategory;
27+
import io.swagger.v3.oas.annotations.Operation;
28+
import io.swagger.v3.oas.annotations.Parameter;
29+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
30+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
31+
import io.swagger.v3.oas.annotations.tags.Tag;
32+
33+
/**
34+
* REST controller for document storage management.
35+
* Provides upload, download, list, and delete operations for organizational documents
36+
* such as policies, training materials, and certifications.
37+
*/
38+
@RestController
39+
@RequestMapping("/api/documents")
40+
@Tag(name = "Documents", description = "Document storage for policies, training materials, and certifications")
41+
@SecurityRequirement(name = "Bearer Auth")
42+
public class DocumentController {
43+
44+
private final DocumentService documentService;
45+
46+
public DocumentController(DocumentService documentService) {
47+
this.documentService = documentService;
48+
}
49+
50+
/**
51+
* Lists all documents for the authenticated user's organization.
52+
*
53+
* @param principal the authenticated user
54+
* @param category optional category filter
55+
* @return list of document metadata
56+
*/
57+
@GetMapping
58+
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
59+
@Operation(
60+
summary = "List documents",
61+
description = "Returns all documents for the organization, optionally filtered by category"
62+
)
63+
@ApiResponses(value = {
64+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Documents retrieved"),
65+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized")
66+
})
67+
public ResponseEntity<ApiResponse<List<DocumentResponse>>> listDocuments(
68+
@AuthenticationPrincipal JwtPrincipal principal,
69+
@RequestParam(required = false) @Parameter(description = "Filter by document category") DocumentCategory category
70+
) {
71+
List<DocumentResponse> documents = documentService
72+
.listDocuments(principal.organizationId(), category)
73+
.stream()
74+
.map(DocumentResponse::from)
75+
.toList();
76+
return ResponseEntity.ok(ApiResponse.success("Documents retrieved", documents));
77+
}
78+
79+
/**
80+
* Downloads a document by its ID.
81+
*
82+
* @param principal the authenticated user
83+
* @param id the document ID
84+
* @return the file content as a byte stream
85+
*/
86+
@GetMapping("/{id}/download")
87+
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
88+
@Operation(
89+
summary = "Download a document",
90+
description = "Downloads the file content of a specific document"
91+
)
92+
@ApiResponses(value = {
93+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "File downloaded"),
94+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"),
95+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized")
96+
})
97+
public ResponseEntity<byte[]> downloadDocument(
98+
@AuthenticationPrincipal JwtPrincipal principal,
99+
@PathVariable Long id
100+
) {
101+
Document doc = documentService.getDocument(id, principal.organizationId());
102+
103+
HttpHeaders headers = new HttpHeaders();
104+
headers.setContentType(MediaType.parseMediaType(doc.getContentType()));
105+
headers.setContentDisposition(
106+
ContentDisposition.attachment().filename(doc.getFileName()).build()
107+
);
108+
headers.setContentLength(doc.getFileSize());
109+
110+
return ResponseEntity.ok().headers(headers).body(doc.getFileData());
111+
}
112+
113+
/**
114+
* Gets document metadata by its ID (without file content).
115+
*
116+
* @param principal the authenticated user
117+
* @param id the document ID
118+
* @return the document metadata
119+
*/
120+
@GetMapping("/{id}")
121+
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
122+
@Operation(
123+
summary = "Get document metadata",
124+
description = "Returns metadata for a specific document without the file content"
125+
)
126+
@ApiResponses(value = {
127+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Document metadata retrieved"),
128+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"),
129+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized")
130+
})
131+
public ResponseEntity<ApiResponse<DocumentResponse>> getDocument(
132+
@AuthenticationPrincipal JwtPrincipal principal,
133+
@PathVariable Long id
134+
) {
135+
Document doc = documentService.getDocument(id, principal.organizationId());
136+
return ResponseEntity.ok(ApiResponse.success("Document retrieved", DocumentResponse.from(doc)));
137+
}
138+
139+
/**
140+
* Uploads a new document.
141+
*
142+
* @param principal the authenticated user
143+
* @param file the file to upload (max 10 MB)
144+
* @param title document title
145+
* @param description optional description
146+
* @param category document category
147+
* @return the created document metadata
148+
*/
149+
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
150+
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')")
151+
@Operation(
152+
summary = "Upload a document",
153+
description = "Uploads a new document (max 10 MB). Accepts policies, training materials, "
154+
+ "certifications, inspection reports, and other documents."
155+
)
156+
@ApiResponses(value = {
157+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Document uploaded"),
158+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file or parameters"),
159+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
160+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden — STAFF cannot upload")
161+
})
162+
public ResponseEntity<ApiResponse<DocumentResponse>> uploadDocument(
163+
@AuthenticationPrincipal JwtPrincipal principal,
164+
@RequestParam("file") MultipartFile file,
165+
@RequestParam("title") String title,
166+
@RequestParam(value = "description", required = false) String description,
167+
@RequestParam("category") DocumentCategory category
168+
) {
169+
Document doc = documentService.uploadDocument(
170+
principal.organizationId(),
171+
principal.userId(),
172+
title,
173+
description,
174+
category,
175+
file
176+
);
177+
return ResponseEntity.status(HttpStatus.CREATED)
178+
.body(ApiResponse.success("Document uploaded", DocumentResponse.from(doc)));
179+
}
180+
181+
/**
182+
* Deletes a document by its ID.
183+
*
184+
* @param principal the authenticated user
185+
* @param id the document ID
186+
* @return success message
187+
*/
188+
@DeleteMapping("/{id}")
189+
@PreAuthorize("hasAnyRole('ADMIN','SUPERVISOR')")
190+
@Operation(
191+
summary = "Delete a document",
192+
description = "Permanently deletes a document. Only ADMIN and SUPERVISOR can delete."
193+
)
194+
@ApiResponses(value = {
195+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Document deleted"),
196+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"),
197+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
198+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden")
199+
})
200+
public ResponseEntity<ApiResponse<Void>> deleteDocument(
201+
@AuthenticationPrincipal JwtPrincipal principal,
202+
@PathVariable Long id
203+
) {
204+
documentService.deleteDocument(id, principal.organizationId());
205+
return ResponseEntity.ok(ApiResponse.success("Document deleted", null));
206+
}
207+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package backend.fullstack.document.api.dto;
2+
3+
import java.time.LocalDateTime;
4+
5+
import backend.fullstack.document.domain.Document;
6+
import backend.fullstack.document.domain.DocumentCategory;
7+
8+
/**
9+
* Response DTO for document metadata (excludes file content).
10+
*/
11+
public record DocumentResponse(
12+
Long id,
13+
String title,
14+
String description,
15+
DocumentCategory category,
16+
String fileName,
17+
String contentType,
18+
Long fileSize,
19+
String uploadedBy,
20+
LocalDateTime createdAt,
21+
LocalDateTime updatedAt
22+
) {
23+
/**
24+
* Maps a {@link Document} entity to a response DTO.
25+
*
26+
* @param doc the document entity
27+
* @return the response DTO
28+
*/
29+
public static DocumentResponse from(Document doc) {
30+
return new DocumentResponse(
31+
doc.getId(),
32+
doc.getTitle(),
33+
doc.getDescription(),
34+
doc.getCategory(),
35+
doc.getFileName(),
36+
doc.getContentType(),
37+
doc.getFileSize(),
38+
doc.getUploadedByName(),
39+
doc.getCreatedAt(),
40+
doc.getUpdatedAt()
41+
);
42+
}
43+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package backend.fullstack.document.application;
2+
3+
import java.io.IOException;
4+
import java.util.List;
5+
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.transaction.annotation.Transactional;
8+
import org.springframework.web.multipart.MultipartFile;
9+
10+
import backend.fullstack.document.domain.Document;
11+
import backend.fullstack.document.domain.DocumentCategory;
12+
import backend.fullstack.document.infrastructure.DocumentRepository;
13+
import backend.fullstack.organization.Organization;
14+
import backend.fullstack.organization.OrganizationRepository;
15+
import backend.fullstack.user.User;
16+
import backend.fullstack.user.UserRepository;
17+
18+
/**
19+
* Service for managing document uploads, retrieval, and deletion.
20+
*/
21+
@Service
22+
@Transactional(readOnly = true)
23+
public class DocumentService {
24+
25+
/** Maximum allowed file size: 10 MB */
26+
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
27+
28+
private final DocumentRepository documentRepository;
29+
private final OrganizationRepository organizationRepository;
30+
private final UserRepository userRepository;
31+
32+
public DocumentService(
33+
DocumentRepository documentRepository,
34+
OrganizationRepository organizationRepository,
35+
UserRepository userRepository
36+
) {
37+
this.documentRepository = documentRepository;
38+
this.organizationRepository = organizationRepository;
39+
this.userRepository = userRepository;
40+
}
41+
42+
/**
43+
* Lists all documents for an organization, optionally filtered by category.
44+
*
45+
* @param organizationId the organization scope
46+
* @param category optional category filter
47+
* @return list of documents (without file content)
48+
*/
49+
public List<Document> listDocuments(Long organizationId, DocumentCategory category) {
50+
if (category != null) {
51+
return documentRepository.findByOrganization_IdAndCategoryOrderByCreatedAtDesc(
52+
organizationId, category);
53+
}
54+
return documentRepository.findByOrganization_IdOrderByCreatedAtDesc(organizationId);
55+
}
56+
57+
/**
58+
* Retrieves a single document by ID, scoped to the organization.
59+
*
60+
* @param id the document ID
61+
* @param organizationId the organization scope
62+
* @return the document entity including file data
63+
* @throws IllegalArgumentException if the document is not found
64+
*/
65+
public Document getDocument(Long id, Long organizationId) {
66+
return documentRepository.findByIdAndOrganization_Id(id, organizationId)
67+
.orElseThrow(() -> new IllegalArgumentException("Document not found: " + id));
68+
}
69+
70+
/**
71+
* Uploads a new document.
72+
*
73+
* @param organizationId the organization scope
74+
* @param userId the uploading user's ID
75+
* @param title document title
76+
* @param description optional description
77+
* @param category document category
78+
* @param file the uploaded file
79+
* @return the persisted document entity
80+
*/
81+
@Transactional
82+
public Document uploadDocument(Long organizationId, Long userId,
83+
String title, String description,
84+
DocumentCategory category, MultipartFile file) {
85+
validateFile(file);
86+
87+
Organization org = organizationRepository.findById(organizationId)
88+
.orElseThrow(() -> new IllegalArgumentException("Organization not found: " + organizationId));
89+
90+
User user = userRepository.findById(userId)
91+
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
92+
93+
byte[] fileData;
94+
try {
95+
fileData = file.getBytes();
96+
} catch (IOException e) {
97+
throw new IllegalStateException("Failed to read uploaded file", e);
98+
}
99+
100+
Document document = Document.builder()
101+
.organization(org)
102+
.uploadedBy(user)
103+
.title(title)
104+
.description(description)
105+
.category(category)
106+
.fileName(file.getOriginalFilename())
107+
.contentType(file.getContentType())
108+
.fileSize(file.getSize())
109+
.fileData(fileData)
110+
.build();
111+
112+
return documentRepository.save(document);
113+
}
114+
115+
/**
116+
* Deletes a document by ID, scoped to the organization.
117+
*
118+
* @param id the document ID
119+
* @param organizationId the organization scope
120+
* @throws IllegalArgumentException if the document is not found
121+
*/
122+
@Transactional
123+
public void deleteDocument(Long id, Long organizationId) {
124+
Document document = documentRepository.findByIdAndOrganization_Id(id, organizationId)
125+
.orElseThrow(() -> new IllegalArgumentException("Document not found: " + id));
126+
documentRepository.delete(document);
127+
}
128+
129+
private void validateFile(MultipartFile file) {
130+
if (file == null || file.isEmpty()) {
131+
throw new IllegalArgumentException("File must not be empty");
132+
}
133+
if (file.getSize() > MAX_FILE_SIZE) {
134+
throw new IllegalArgumentException(
135+
"File size exceeds maximum allowed size of " + (MAX_FILE_SIZE / 1024 / 1024) + " MB");
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)