diff --git a/src/main/java/com/chaineeproject/chainee/controller/ContractNotificationController.java b/src/main/java/com/chaineeproject/chainee/controller/ContractNotificationController.java new file mode 100644 index 0000000..3fb0ab5 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/controller/ContractNotificationController.java @@ -0,0 +1,72 @@ +package com.chaineeproject.chainee.controller; + +import com.chaineeproject.chainee.dto.ContractNotificationRequest; +import com.chaineeproject.chainee.dto.ContractNotificationResponse; +import com.chaineeproject.chainee.service.EmployerApplicationService; +import com.chaineeproject.chainee.security.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job/applications") +@Tag(name = "Contract Notification", description = "계약 전자서명 관련 알림 API") +@SecurityRequirement(name = "bearerAuth") +public class ContractNotificationController { + + private final EmployerApplicationService employerApplicationService; + + private Long meId(Jwt jwt) { + return SecurityUtils.uidOrNull(jwt); + } + + /** + * 구인자 → 구직자 : 계약서 전자 서명 요청 알림 + * 예: POST /api/job/applications/{applicationId}/contract-request + * body: { "transaction": "{...raw tx...}" } + */ + @PostMapping("/{applicationId}/contract-request") + @Operation( + summary = "계약서 전자 서명 요청 알림 (구인자 → 구직자)", + description = "공고 작성자가 지원자에게 계약서 전자 서명을 요청하는 알림을 생성합니다." + ) + public ResponseEntity sendContractRequest( + @AuthenticationPrincipal Jwt jwt, + @PathVariable Long applicationId, + @RequestBody ContractNotificationRequest request + ) { + Long employerId = meId(jwt); + if (employerId == null) return ResponseEntity.status(401).build(); + + var res = employerApplicationService.sendContractNotification(employerId, applicationId, request); + return ResponseEntity.ok(res); + } + + /** + * 🔥 구직자 → 구인자 : 서명 완료 알림 + * 예: POST /api/job/applications/{applicationId}/contract-signed + * body: { "transaction": "{...signed tx...}" } + */ + @PostMapping("/{applicationId}/contract-signed") + @Operation( + summary = "계약서 전자 서명 완료 알림 (구직자 → 구인자)", + description = "지원자가 계약서 전자 서명을 완료했음을 공고 작성자에게 알리는 알림을 생성합니다." + ) + public ResponseEntity sendContractSigned( + @AuthenticationPrincipal Jwt jwt, + @PathVariable Long applicationId, + @RequestBody ContractNotificationRequest request + ) { + Long applicantId = meId(jwt); + if (applicantId == null) return ResponseEntity.status(401).build(); + + var res = employerApplicationService.sendSignedContractNotification(applicantId, applicationId, request); + return ResponseEntity.ok(res); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/controller/EmployerApplicationController.java b/src/main/java/com/chaineeproject/chainee/controller/EmployerApplicationController.java index d591b4e..14c0aab 100644 --- a/src/main/java/com/chaineeproject/chainee/controller/EmployerApplicationController.java +++ b/src/main/java/com/chaineeproject/chainee/controller/EmployerApplicationController.java @@ -1,9 +1,7 @@ // src/main/java/com/chaineeproject/chainee/controller/EmployerApplicationController.java package com.chaineeproject.chainee.controller; -import com.chaineeproject.chainee.dto.ApplicationAcceptResponse; -import com.chaineeproject.chainee.dto.PostApplicantsView; -import com.chaineeproject.chainee.dto.ResumeView; +import com.chaineeproject.chainee.dto.*; import com.chaineeproject.chainee.service.EmployerApplicationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -23,9 +21,13 @@ public class EmployerApplicationController { private final EmployerApplicationService employerApplicationService; - private Long meId(Jwt jwt) { return Long.valueOf(jwt.getSubject()); } + private Long meId(Jwt jwt) { + return Long.valueOf(jwt.getSubject()); + } - /** 지원 수락 */ + /** + * 지원 수락 + */ @PostMapping("/job/applications/{applicationId}/accept") @Operation(summary = "지원 수락", description = "구인자(작성자)가 특정 지원서를 수락합니다. 응답으로 구직자 DID, payment, deadline을 반환합니다.") @@ -37,7 +39,9 @@ public ResponseEntity accept( return ResponseEntity.ok(res); } - /** 공고 지원자 관리 목록 */ + /** + * 공고 지원자 관리 목록 + */ @GetMapping("/job/posts/{postId}/applicants") @Operation(summary = "공고 지원자 관리 목록", description = "공고 제목, 총 지원자 수, 지원자 리스트(이름/지원 날짜/positions)를 반환합니다.") @@ -50,7 +54,9 @@ public ResponseEntity applicantsOfPost( ); } - /** 구인자 전용 이력서 열람 (applicationId로 접근) */ + /** + * 구인자 전용 이력서 열람 (applicationId로 접근) + */ @GetMapping("/job/applications/{applicationId}/resume") @Operation(summary = "지원서 기반 이력서 조회(구인자용)", description = "공고 작성자가 자신에게 접수된 지원서의 이력서를 열람합니다.") diff --git a/src/main/java/com/chaineeproject/chainee/dto/ContractNotificationRequest.java b/src/main/java/com/chaineeproject/chainee/dto/ContractNotificationRequest.java new file mode 100644 index 0000000..fd6f31d --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/dto/ContractNotificationRequest.java @@ -0,0 +1,14 @@ +// src/main/java/com/chaineeproject/chainee/dto/ContractNotificationRequest.java +package com.chaineeproject.chainee.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "계약서 전자서명 요청 알림 생성 요청") +public record ContractNotificationRequest( + + @Schema( + description = "프론트에서 생성한 트랜잭션 문자열 그대로 (JSON이든 base64든 어떤 형식이든 상관 없음)", + example = "{ \"chain\": \"solana\", \"network\": \"devnet\", \"tx\": \"...\" }" + ) + String transaction +) {} diff --git a/src/main/java/com/chaineeproject/chainee/dto/ContractNotificationResponse.java b/src/main/java/com/chaineeproject/chainee/dto/ContractNotificationResponse.java new file mode 100644 index 0000000..81e9ca8 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/dto/ContractNotificationResponse.java @@ -0,0 +1,17 @@ +// src/main/java/com/chaineeproject/chainee/dto/ContractNotificationResponse.java +package com.chaineeproject.chainee.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "계약서 전자서명 요청 알림 생성 응답") +public record ContractNotificationResponse( + + @Schema(description = "성공 여부", example = "true") + boolean success, + + @Schema(description = "메시지 코드", example = "CONTRACT_NOTIFICATION_SENT") + String messageCode, + + @Schema(description = "생성된 알림 ID", example = "101") + Long notificationId +) {} diff --git a/src/main/java/com/chaineeproject/chainee/entity/enums/NotificationType.java b/src/main/java/com/chaineeproject/chainee/entity/enums/NotificationType.java index 4c0ecab..fffca7e 100644 --- a/src/main/java/com/chaineeproject/chainee/entity/enums/NotificationType.java +++ b/src/main/java/com/chaineeproject/chainee/entity/enums/NotificationType.java @@ -1,5 +1,7 @@ package com.chaineeproject.chainee.entity.enums; public enum NotificationType { - JOB_APPLICATION_RECEIVED + JOB_APPLICATION_RECEIVED, + CONTRACT_SIGNATURE_REQUEST, + CONTRACT_SIGNED } \ No newline at end of file diff --git a/src/main/java/com/chaineeproject/chainee/service/EmployerApplicationService.java b/src/main/java/com/chaineeproject/chainee/service/EmployerApplicationService.java index 49ea8a0..9af47fb 100644 --- a/src/main/java/com/chaineeproject/chainee/service/EmployerApplicationService.java +++ b/src/main/java/com/chaineeproject/chainee/service/EmployerApplicationService.java @@ -1,19 +1,21 @@ // src/main/java/com/chaineeproject/chainee/service/EmployerApplicationService.java package com.chaineeproject.chainee.service; -import com.chaineeproject.chainee.dto.ApplicantSummary; -import com.chaineeproject.chainee.dto.ApplicationAcceptResponse; -import com.chaineeproject.chainee.dto.PostApplicantsView; -import com.chaineeproject.chainee.dto.ResumeView; +import com.chaineeproject.chainee.dto.*; import com.chaineeproject.chainee.entity.JobApplication; import com.chaineeproject.chainee.entity.JobPost; +import com.chaineeproject.chainee.entity.Notification; +import com.chaineeproject.chainee.entity.Resume; import com.chaineeproject.chainee.entity.User; +import com.chaineeproject.chainee.entity.enums.NotificationType; import com.chaineeproject.chainee.exception.ApplicationException; import com.chaineeproject.chainee.exception.ErrorCode; import com.chaineeproject.chainee.repository.JobApplicationRepository; import com.chaineeproject.chainee.repository.JobPostRepository; +import com.chaineeproject.chainee.repository.NotificationRepository; import com.chaineeproject.chainee.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,13 +23,17 @@ @Service @RequiredArgsConstructor +@Slf4j public class EmployerApplicationService { private final JobApplicationRepository jobApplicationRepository; private final JobPostRepository jobPostRepository; private final UserRepository userRepository; + private final NotificationRepository notificationRepository; - /** 지원 수락: applicant DID + post payment + deadline 반환 */ + /** + * 지원 수락: applicant DID + post payment + deadline 반환 + */ @Transactional public ApplicationAcceptResponse acceptApplication(Long applicationId, Long employerUserId) { JobApplication app = jobApplicationRepository.findByIdWithPostAndUsers(applicationId) @@ -63,7 +69,9 @@ public ApplicationAcceptResponse acceptApplication(Long applicationId, Long empl ); } - /** 공고별 지원자 관리 목록 */ + /** + * 공고별 지원자 관리 목록 + */ @Transactional(readOnly = true) public PostApplicantsView getApplicantsOfPost(Long employerUserId, Long postId) { JobPost post = jobPostRepository.findById(postId) @@ -83,25 +91,29 @@ public PostApplicantsView getApplicantsOfPost(Long employerUserId, Long postId) a.getApplicant().getPositions() == null ? List.of() : a.getApplicant().getPositions() )).toList(); - long total = post.getApplicantCount(); // 실시간이면 apps.size() + long total = post.getApplicantCount(); // 또는 apps.size() 사용도 가능 return new PostApplicantsView(post.getId(), post.getTitle(), total, items); } - /** 구인자 전용: 특정 지원서의 이력서를 열람 */ + /** + * 구인자 전용: 특정 지원서의 이력서를 열람 + */ @Transactional(readOnly = true) public ResumeView getResumeForEmployer(Long employerUserId, Long applicationId) { - var app = jobApplicationRepository.findByIdWithPostApplicantAndResume(applicationId) + JobApplication app = jobApplicationRepository.findByIdWithPostApplicantAndResume(applicationId) .orElseThrow(() -> new ApplicationException(ErrorCode.JOB_APPLICATION_NOT_FOUND)); if (!app.getPost().getAuthor().getId().equals(employerUserId)) { throw new ApplicationException(ErrorCode.FORBIDDEN); } - var r = app.getResume(); + Resume r = app.getResume(); var skills = (r.getSkills() == null || r.getSkills().isBlank()) - ? java.util.List.of() + ? List.of() : java.util.Arrays.stream(r.getSkills().split(",")) - .map(String::trim).filter(s -> !s.isBlank()).toList(); + .map(String::trim) + .filter(s -> !s.isBlank()) + .toList(); return new ResumeView( r.getId(), @@ -115,4 +127,125 @@ public ResumeView getResumeForEmployer(Long employerUserId, Long applicationId) r.getCreatedAt() ); } + + /** + * 구인자 → 구직자에게 계약 전자서명 요청 알림 생성 + * - 프론트는 transaction 문자열만 넘긴다. + * - 백엔드는 transaction을 그대로 Notification에 저장하고, + * 타이틀/메시지만 사람이 읽을 수 있게 세팅. + */ + @Transactional + public ContractNotificationResponse sendContractNotification( + Long employerId, + Long applicationId, + ContractNotificationRequest req + ) { + // 1) 지원서 + 연관 엔티티 로딩 + JobApplication app = jobApplicationRepository.findByIdWithPostAndUsers(applicationId) + .orElseThrow(() -> new ApplicationException(ErrorCode.JOB_APPLICATION_NOT_FOUND)); + + JobPost post = app.getPost(); + User applicant = app.getApplicant(); + User author = post.getAuthor(); + + // 2) 권한 체크: 요청자 == 공고 작성자(구인자) + if (!author.getId().equals(employerId)) { + throw new ApplicationException(ErrorCode.FORBIDDEN); + } + + // 3) transaction 필수 체크 + if (req.transaction() == null || req.transaction().isBlank()) { + throw new ApplicationException(ErrorCode.INVALID_REQUEST); + } + + // 4) 알림 내용 구성 + String title = "계약서 전자 서명이 필요합니다"; + String message = String.format( + "'%s' 공고에 대한 계약서 전자 서명을 진행해 주세요.", + nz(post.getTitle(), "공고") + ); + + // 5) 알림 생성 + Notification noti = Notification.builder() + .recipient(applicant) // 알림 받는 사람 = 구직자 + .actor(author) // 알림을 유발한 사람 = 구인자 + .notificationType(NotificationType.CONTRACT_SIGNATURE_REQUEST) + .title(title) + .message(message) + // linkUrl은 프론트 라우팅 전략에 따라 사용/미사용 + .linkUrl(null) + .build(); + + notificationRepository.save(noti); + + log.debug("Contract signature notification created: notiId={}, applicationId={}", + noti.getId(), applicationId); + + return new ContractNotificationResponse( + true, + "CONTRACT_NOTIFICATION_SENT", + noti.getId() + ); + } + + private static String nz(String s, String def) { + return (s == null || s.isBlank()) ? def : s; + } + + /** + * 🔥 새로 추가: 구직자 → 구인자에게 "서명 완료" 알림 생성 + * - applicantId: JWT uid (요청자 = 구직자) + * - applicationId: 지원 ID + * - req.transaction: 서명된 트랜잭션(raw string) + */ + @Transactional + public ContractNotificationResponse sendSignedContractNotification( + Long applicantId, + Long applicationId, + ContractNotificationRequest req + ) { + JobApplication app = jobApplicationRepository.findByIdWithPostAndUsers(applicationId) + .orElseThrow(() -> new ApplicationException(ErrorCode.JOB_APPLICATION_NOT_FOUND)); + + JobPost post = app.getPost(); + User applicant = app.getApplicant(); + User author = post.getAuthor(); // 구인자 + + // 권한: 요청자 == 지원자 + if (!applicant.getId().equals(applicantId)) { + throw new ApplicationException(ErrorCode.FORBIDDEN); + } + + if (req.transaction() == null || req.transaction().isBlank()) { + throw new ApplicationException(ErrorCode.INVALID_REQUEST); + } + + String title = "계약서 전자 서명이 완료되었습니다"; + String message = String.format( + "%s님이 '%s' 공고에 대한 계약서 전자 서명을 완료했습니다.", + nz(applicant.getName(), "지원자"), + nz(post.getTitle(), "공고") + ); + + Notification noti = Notification.builder() + .recipient(author) // 알림 받는 사람 = 구인자 + .actor(applicant) // 알림 유발 = 구직자 + .notificationType(NotificationType.CONTRACT_SIGNED) + .title(title) + .message(message) + .linkUrl(null) + .build(); + + notificationRepository.save(noti); + + log.debug("Contract signed notification created: notiId={}, applicationId={}", + noti.getId(), applicationId); + + return new ContractNotificationResponse( + true, + "CONTRACT_SIGNED_NOTIFICATION_SENT", + noti.getId() + ); + } + }