Skip to content

Commit 051a34d

Browse files
authored
Merge pull request #448 from bounswe/feat/440-resume-file-upload-endpoint
Feat/440 resume file upload endpoint
2 parents 685a6fa + cd818d4 commit 051a34d

File tree

8 files changed

+246
-1
lines changed

8 files changed

+246
-1
lines changed

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/exception/ErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ public enum ErrorCode {
8282
ACTIVE_MENTORSHIP_EXIST(HttpStatus.CONFLICT),
8383
MENTEE_CAPACITY_CONFLICT(HttpStatus.CONFLICT),
8484
MENTOR_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND),
85-
MENTOR_PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT);
85+
MENTOR_PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT),
86+
RESUME_FILE_REQUIRED(HttpStatus.BAD_REQUEST),
87+
RESUME_FILE_CONTENT_TYPE_INVALID(HttpStatus.UNSUPPORTED_MEDIA_TYPE),
88+
RESUME_FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR),
89+
RESUME_FILE_NOT_FOUND(HttpStatus.NOT_FOUND);
8690

8791

8892
public final HttpStatus status;

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/mentorship/controller/MentorshipController.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.springframework.security.access.prepost.PreAuthorize;
1111
import org.springframework.security.core.Authentication;
1212
import org.springframework.web.bind.annotation.*;
13+
import org.springframework.web.multipart.MultipartFile;
1314

1415
import java.util.List;
1516

@@ -21,6 +22,32 @@ public class MentorshipController {
2122

2223
private final MentorshipService mentorshipService;
2324

25+
@PostMapping("/{resumeReviewId}/file")
26+
public ResponseEntity<ResumeFileResponseDTO> uploadResumeFile(
27+
@PathVariable Long resumeReviewId,
28+
@RequestPart("file") MultipartFile file
29+
) {
30+
ResumeFileResponseDTO dto = mentorshipService.uploadResumeFile(resumeReviewId, file);
31+
return ResponseEntity.ok(dto);
32+
}
33+
34+
@GetMapping("/{resumeReviewId}/file")
35+
public ResponseEntity<ResumeFileUrlDTO> getResumeFileUrl(
36+
@PathVariable Long resumeReviewId
37+
) {
38+
ResumeFileUrlDTO dto = mentorshipService.getResumeFileUrl(resumeReviewId);
39+
return ResponseEntity.ok(dto);
40+
}
41+
42+
@GetMapping("/{resumeReviewId}")
43+
public ResponseEntity<ResumeReviewDTO> getResumeReview(
44+
@PathVariable Long resumeReviewId
45+
) {
46+
ResumeReviewDTO dto = mentorshipService.getResumeReview(resumeReviewId);
47+
return ResponseEntity.ok(dto);
48+
}
49+
50+
2451
@GetMapping
2552
public ResponseEntity<List<MentorProfileDetailDTO>> searchMentors() {
2653
List<MentorProfileDetailDTO> mentors = mentorshipService.searchMentors();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package org.bounswe.jobboardbackend.mentorship.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
import org.bounswe.jobboardbackend.mentorship.model.ReviewStatus;
8+
9+
10+
import java.time.LocalDateTime;
11+
12+
@Data
13+
@Builder
14+
@AllArgsConstructor
15+
@NoArgsConstructor
16+
public class ResumeFileResponseDTO {
17+
18+
private Long resumeReviewId;
19+
private String fileUrl;
20+
private ReviewStatus reviewStatus;
21+
private LocalDateTime uploadedAt;
22+
}
23+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.bounswe.jobboardbackend.mentorship.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
8+
@Data
9+
@Builder
10+
@AllArgsConstructor
11+
@NoArgsConstructor
12+
public class ResumeFileUrlDTO {
13+
String fileUrl;
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.bounswe.jobboardbackend.mentorship.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
import org.bounswe.jobboardbackend.mentorship.model.ReviewStatus;
8+
9+
@Data
10+
@Builder
11+
@AllArgsConstructor
12+
@NoArgsConstructor
13+
public class ResumeReviewDTO {
14+
private Long resumeReviewId;
15+
private String fileUrl;
16+
private ReviewStatus reviewStatus;
17+
private String feedback;
18+
}

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/mentorship/model/ResumeReview.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,8 @@ public class ResumeReview {
3535
@OneToOne(mappedBy = "resumeReview", cascade = CascadeType.ALL)
3636
private Conversation conversation;
3737

38+
private String resumeUrl;
39+
40+
private LocalDateTime resumeUploadedAt;
41+
3842
}

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/mentorship/service/MentorshipService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33

44
import org.bounswe.jobboardbackend.mentorship.dto.*;
55
import org.springframework.security.core.Authentication;
6+
import org.springframework.web.multipart.MultipartFile;
67

78
import java.util.List;
89

910
public interface MentorshipService {
1011

1112

1213

14+
ResumeFileResponseDTO uploadResumeFile(Long resumeReviewId, MultipartFile file);
15+
ResumeFileUrlDTO getResumeFileUrl(Long resumeReviewId);
16+
ResumeReviewDTO getResumeReview(Long resumeReviewId);
17+
1318
List<MentorshipDetailsDTO> getMentorshipDetailsForMentee(Long menteeId, Long currentUserId);
1419
List<MentorProfileDetailDTO> searchMentors();
1520
MentorProfileDTO createMentorProfile(Long userId, CreateMentorProfileDTO createDTO);

apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/mentorship/service/MentorshipServiceImpl.java

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.bounswe.jobboardbackend.mentorship.service;
22

3+
import com.google.cloud.storage.*;
34
import lombok.RequiredArgsConstructor;
45
import org.bounswe.jobboardbackend.auth.model.User;
56
import org.bounswe.jobboardbackend.auth.repository.UserRepository;
@@ -9,13 +10,19 @@
910
import org.bounswe.jobboardbackend.mentorship.dto.*;
1011
import org.bounswe.jobboardbackend.mentorship.model.*;
1112
import org.bounswe.jobboardbackend.mentorship.repository.*;
13+
import org.springframework.beans.factory.annotation.Value;
1214
import org.springframework.messaging.simp.SimpMessagingTemplate;
1315
import org.springframework.security.access.AccessDeniedException;
1416
import org.springframework.security.core.Authentication;
1517
import org.springframework.stereotype.Service;
1618
import org.springframework.transaction.annotation.Transactional;
19+
import org.springframework.web.multipart.MultipartFile;
20+
21+
import java.io.IOException;
22+
import java.net.URL;
1723
import java.time.LocalDateTime;
1824
import java.util.List;
25+
import java.util.concurrent.TimeUnit;
1926
import java.util.stream.Collectors;
2027
import java.util.stream.Stream;
2128

@@ -31,9 +38,101 @@ public class MentorshipServiceImpl implements MentorshipService {
3138
private final ChatService chatService;
3239
private final SimpMessagingTemplate messagingTemplate;
3340
private final ConversationRepository conversationRepository;
41+
private final Storage storage = StorageOptions.getDefaultInstance().getService();
3442
// private final NotificationService notificationService; // (Future implementation)
3543

3644

45+
@Value("${app.gcs.bucket}")
46+
private String gcsBucket;
47+
48+
@Value("${app.gcs.public}")
49+
private boolean gcsPublic;
50+
51+
@Value("${app.gcs.publicBaseUrl}")
52+
private String gcsPublicBaseUrl;
53+
54+
@Value("${app.env}")
55+
private String appEnv;
56+
57+
58+
@Override
59+
@Transactional
60+
public ResumeFileResponseDTO uploadResumeFile(Long resumeReviewId, MultipartFile file) {
61+
if (file == null || file.isEmpty()) {
62+
throw new HandleException(ErrorCode.RESUME_FILE_REQUIRED, "Resume file is required");
63+
}
64+
65+
String ct = file.getContentType();
66+
if (!"application/pdf".equalsIgnoreCase(ct)) {
67+
throw new HandleException(ErrorCode.RESUME_FILE_CONTENT_TYPE_INVALID, "Only PDF files are allowed");
68+
}
69+
70+
ResumeReview review = resumeReviewRepository.findById(resumeReviewId)
71+
.orElseThrow(() -> new HandleException(ErrorCode.RESUME_REVIEW_NOT_FOUND, "Resume review not found"));
72+
73+
if (review.getResumeUrl() != null) {
74+
String oldObject = extractObjectNameFromUrl(review.getResumeUrl());
75+
if (oldObject != null) {
76+
deleteFromGcs(oldObject);
77+
}
78+
}
79+
80+
String objectName = buildObjectNameForResume(resumeReviewId, file.getOriginalFilename());
81+
String url;
82+
try {
83+
url = uploadToGcs(file.getBytes(), ct, objectName);
84+
} catch (IOException e) {
85+
throw new HandleException(ErrorCode.RESUME_FILE_UPLOAD_FAILED, "Upload failed", e);
86+
}
87+
88+
LocalDateTime now = LocalDateTime.now();
89+
review.setResumeUrl(url);
90+
//review.setStatus(ReviewStatus.ACTIVE);
91+
review.setResumeUploadedAt(now);
92+
93+
return ResumeFileResponseDTO.builder()
94+
.resumeReviewId(review.getId())
95+
.fileUrl(review.getResumeUrl())
96+
.reviewStatus(review.getStatus())
97+
.uploadedAt(review.getResumeUploadedAt())
98+
.build();
99+
}
100+
101+
102+
@Override
103+
@Transactional(readOnly = true)
104+
public ResumeFileUrlDTO getResumeFileUrl(Long resumeReviewId) {
105+
ResumeReview review = resumeReviewRepository.findById(resumeReviewId)
106+
.orElseThrow(() -> new HandleException(ErrorCode.RESUME_REVIEW_NOT_FOUND, "Resume review not found"));
107+
108+
if (review.getResumeUrl() == null) {
109+
throw new HandleException(ErrorCode.RESUME_FILE_NOT_FOUND, "Resume file not uploaded yet");
110+
}
111+
112+
return ResumeFileUrlDTO.builder()
113+
.fileUrl(review.getResumeUrl())
114+
.build();
115+
}
116+
117+
118+
@Override
119+
@Transactional(readOnly = true)
120+
public ResumeReviewDTO getResumeReview(Long resumeReviewId) {
121+
ResumeReview review = resumeReviewRepository.findById(resumeReviewId)
122+
.orElseThrow(() -> new HandleException(ErrorCode.RESUME_REVIEW_NOT_FOUND, "Resume review not found"));
123+
124+
return ResumeReviewDTO.builder()
125+
.resumeReviewId(review.getId())
126+
.fileUrl(review.getResumeUrl())
127+
.reviewStatus(review.getStatus())
128+
.feedback(review.getFeedback())
129+
.build();
130+
}
131+
132+
133+
134+
135+
37136
@Override
38137
@Transactional(readOnly = true)
39138
public List<MentorProfileDetailDTO> searchMentors() {
@@ -404,4 +503,55 @@ private MentorshipRequestDTO toMentorshipRequestDTO(MentorshipRequest request) {
404503
request.getCreatedAt()
405504
);
406505
}
506+
507+
private String buildObjectNameForResume(Long resumeReviewId, String originalFilename) {
508+
String ext = ".pdf";
509+
if (originalFilename != null && originalFilename.contains(".")) {
510+
String candidate = originalFilename.substring(originalFilename.lastIndexOf('.'));
511+
if (candidate.equalsIgnoreCase(".pdf")) {
512+
ext = candidate;
513+
}
514+
}
515+
return appEnv + "/resumes/" + resumeReviewId + ext;
516+
}
517+
518+
private String publicUrl(String objectName) {
519+
return gcsPublicBaseUrl + "/" + gcsBucket + "/" + objectName;
520+
}
521+
522+
private String extractObjectNameFromUrl(String url) {
523+
String prefix = gcsPublicBaseUrl + "/" + gcsBucket + "/";
524+
if (url != null && url.startsWith(prefix)) {
525+
return url.substring(prefix.length());
526+
}
527+
return null;
528+
}
529+
530+
private String uploadToGcs(byte[] content, String contentType, String objectName) {
531+
BlobInfo info = BlobInfo.newBuilder(gcsBucket, objectName)
532+
.setContentType(contentType != null ? contentType : "application/pdf")
533+
.build();
534+
storage.create(info, content);
535+
536+
if (gcsPublic) {
537+
return publicUrl(objectName);
538+
} else {
539+
URL signed = storage.signUrl(
540+
BlobInfo.newBuilder(gcsBucket, objectName).build(),
541+
15, TimeUnit.MINUTES,
542+
Storage.SignUrlOption.withV4Signature(),
543+
Storage.SignUrlOption.httpMethod(HttpMethod.GET)
544+
);
545+
return signed.toString();
546+
}
547+
}
548+
549+
private void deleteFromGcs(String objectName) {
550+
if (objectName == null) return;
551+
try {
552+
storage.delete(gcsBucket, objectName);
553+
} catch (StorageException ignore) {
554+
}
555+
}
556+
407557
}

0 commit comments

Comments
 (0)