diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d3146fe8..f6de02ea 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -33,7 +33,12 @@ jobs: run: | cd backend/src/main/resources touch ./application-local.yml + touch ./application-docker.yml echo "${{ secrets.TEST_PROPERTIES }}" > ./application-local.yml + echo "${{ secrets.DOCKER_TEST_PROPERTIES }}" > ./application-docker.yml + cd ../../test/resources + touch ./application-test.yml + echo "${{ secrets.INTEGRATION_TEST_PROPERTIES }}" > ./application-test.yml shell: bash - name: Test with Gradle diff --git a/.gitignore b/.gitignore index 780c0487..bf12d04c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ logs/ logs/ backend/src/main/resources/application-local.yml +backend/src/test/resources/application-test.yml backend/src/main/resources/static/docs/ diff --git a/backend/build.gradle b/backend/build.gradle index ba79f687..a7739985 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -39,6 +39,8 @@ dependencies { // spring-boot implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.json:json:20210307' @@ -59,7 +61,11 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'com.h2database:h2' + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' + testImplementation 'org.testcontainers:testcontainers:1.19.0' + testImplementation 'org.testcontainers:junit-jupiter:1.19.0' + testImplementation "org.testcontainers:mysql:1.19.0" + //rest-docs testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' diff --git a/backend/src/main/java/com/graphy/backend/domain/bookmark/domain/Bookmark.java b/backend/src/main/java/com/graphy/backend/domain/bookmark/domain/Bookmark.java new file mode 100644 index 00000000..42fd8fb7 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/bookmark/domain/Bookmark.java @@ -0,0 +1,29 @@ +package com.graphy.backend.domain.bookmark.domain; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Bookmark { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recruitment_id") + private Recruitment recruitment; +} diff --git a/backend/src/main/java/com/graphy/backend/domain/comment/domain/Comment.java b/backend/src/main/java/com/graphy/backend/domain/comment/domain/Comment.java index 512510e5..7c74f7b0 100644 --- a/backend/src/main/java/com/graphy/backend/domain/comment/domain/Comment.java +++ b/backend/src/main/java/com/graphy/backend/domain/comment/domain/Comment.java @@ -16,6 +16,11 @@ @Entity @AllArgsConstructor @Builder + +/* + * TODO + * Hard Delete 하기 위해서 제거해야 될 거 같아요 + */ @SQLDelete(sql = "UPDATE comment SET is_deleted = true WHERE comment_id = ?") public class Comment extends BaseEntity { diff --git a/backend/src/main/java/com/graphy/backend/domain/follow/domain/Follow.java b/backend/src/main/java/com/graphy/backend/domain/follow/domain/Follow.java index 1c861958..09f35fd2 100644 --- a/backend/src/main/java/com/graphy/backend/domain/follow/domain/Follow.java +++ b/backend/src/main/java/com/graphy/backend/domain/follow/domain/Follow.java @@ -1,6 +1,5 @@ package com.graphy.backend.domain.follow.domain; -import com.graphy.backend.domain.member.domain.Member; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/backend/src/main/java/com/graphy/backend/domain/follow/service/FollowService.java b/backend/src/main/java/com/graphy/backend/domain/follow/service/FollowService.java index ddd7a86a..2af3a236 100644 --- a/backend/src/main/java/com/graphy/backend/domain/follow/service/FollowService.java +++ b/backend/src/main/java/com/graphy/backend/domain/follow/service/FollowService.java @@ -5,39 +5,63 @@ import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.member.dto.response.GetMemberListResponse; import com.graphy.backend.domain.member.repository.MemberRepository; +import com.graphy.backend.domain.member.service.MemberService; +import com.graphy.backend.domain.notification.domain.NotificationType; +import com.graphy.backend.domain.notification.dto.NotificationDto; +import com.graphy.backend.domain.notification.service.NotificationService; import com.graphy.backend.global.error.ErrorCode; import com.graphy.backend.global.error.exception.AlreadyExistException; import com.graphy.backend.global.error.exception.EmptyResultException; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import javax.transaction.Transactional; import java.util.List; @RequiredArgsConstructor(access = AccessLevel.PROTECTED) @Service -@Transactional +@Transactional(readOnly = true) public class FollowService { private final FollowRepository followRepository; private final MemberRepository memberRepository; + private final NotificationService notificationService; + private final MemberService memberService; + + + @Transactional public void addFollow(Long toId, Member loginUser) { + memberService.findMemberById(loginUser.getId()); + Long fromId = loginUser.getId(); - checkFollowingAlready(loginUser.getId(), toId); + checkFollowAvailable(loginUser.getId(), toId); + Follow follow = Follow.builder().fromId(fromId).toId(toId).build(); + NotificationType notificationType = NotificationType.FOLLOW; + notificationType.setMessage(loginUser.getNickname(), ""); + NotificationDto notificationDto = NotificationDto.builder() + .type(notificationType) + .content(notificationType.getMessage()) + .build(); + followRepository.save(follow); memberRepository.increaseFollowerCount(toId); memberRepository.increaseFollowingCount(fromId); + + notificationService.addNotification(notificationDto, toId); } + @Transactional public void removeFollow(Long toId, Member loginUser) { Long fromId = loginUser.getId(); Follow follow = followRepository.findByFromIdAndToId(fromId, toId).orElseThrow( () -> new EmptyResultException(ErrorCode.FOLLOW_NOT_EXIST) ); followRepository.delete(follow); + + // TODO: memberService의 메소드로 분리 memberRepository.decreaseFollowerCount(toId); memberRepository.decreaseFollowingCount(fromId); } @@ -50,9 +74,12 @@ public List findFollowingList(Member loginUser) { return followRepository.findFollowings(loginUser.getId()); } - public void checkFollowingAlready(Long fromId, Long toId) { + public void checkFollowAvailable(Long fromId, Long toId) { if (followRepository.existsByFromIdAndToId(fromId, toId)) { throw new AlreadyExistException(ErrorCode.FOLLOW_ALREADY_EXIST); } + if (fromId.equals(toId)) { + throw new AlreadyExistException(ErrorCode.FOLLOW_SELF); + } } } diff --git a/backend/src/main/java/com/graphy/backend/domain/job/controller/JobController.java b/backend/src/main/java/com/graphy/backend/domain/job/controller/JobController.java index 3f1d9bea..12e5314e 100644 --- a/backend/src/main/java/com/graphy/backend/domain/job/controller/JobController.java +++ b/backend/src/main/java/com/graphy/backend/domain/job/controller/JobController.java @@ -1,11 +1,19 @@ package com.graphy.backend.domain.job.controller; +import com.graphy.backend.domain.job.dto.response.GetJobResponse; import com.graphy.backend.domain.job.service.JobService; +import com.graphy.backend.global.common.PageRequest; +import com.graphy.backend.global.error.ErrorCode; +import com.graphy.backend.global.error.exception.EmptyResultException; +import com.graphy.backend.global.result.ResultCode; +import com.graphy.backend.global.result.ResultResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @Tag(name = "EmploymentController", description = "신규 채용 공고 API") @@ -15,18 +23,13 @@ public class JobController { private final JobService jobService; - @Operation(summary = "Save JobInfo", description = "신규 공고 저장") - @Scheduled(cron = "0 0 0 */3 * *") - @PostMapping - public void save(){ - jobService.save(); - } - + @Operation(summary = "findJobList", description = "채용공고 조회") + @GetMapping + public ResponseEntity jobList(PageRequest pageRequest) { + Pageable pageable = pageRequest.jobOf(); + List result = jobService.findJobList(pageable); + if (result.isEmpty()) throw new EmptyResultException(ErrorCode.JOB_DELETED_OR_NOT_EXIST); - @Operation(summary = "Delete Job", description = "만료일이 지난 공고 삭제") - @Scheduled(cron = "0 0 0 * * *") - @DeleteMapping - public void deleteExpiredJobs(){ - jobService.deleteExpiredJobs(); + return ResponseEntity.ok(ResultResponse.of(ResultCode.JOB_PAGING_GET_SUCCESS, result)); } } diff --git a/backend/src/main/java/com/graphy/backend/domain/job/domain/Job.java b/backend/src/main/java/com/graphy/backend/domain/job/domain/Job.java index f855901e..80081a5f 100644 --- a/backend/src/main/java/com/graphy/backend/domain/job/domain/Job.java +++ b/backend/src/main/java/com/graphy/backend/domain/job/domain/Job.java @@ -11,6 +11,7 @@ public class Job { @Id @Column(name = "job_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) diff --git a/backend/src/main/java/com/graphy/backend/domain/job/dto/JobDto.java b/backend/src/main/java/com/graphy/backend/domain/job/dto/JobDto.java deleted file mode 100644 index 1fb97851..00000000 --- a/backend/src/main/java/com/graphy/backend/domain/job/dto/JobDto.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.graphy.backend.domain.job.dto; - -import com.graphy.backend.domain.job.domain.Job; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -public class JobDto { - - @Getter - @AllArgsConstructor - @NoArgsConstructor - public static class CreateJobInfoRequest { - private Long id; - private String companyName; - - private String title; - - private String url; - - private LocalDateTime expirationDate; - - public Job toEntity() { - return Job.builder() - .id(id) - .companyName(companyName) - .title(title) - .url(url) - .expirationDate(expirationDate) - .build(); - } - } -} diff --git a/backend/src/main/java/com/graphy/backend/domain/job/dto/response/GetJobResponse.java b/backend/src/main/java/com/graphy/backend/domain/job/dto/response/GetJobResponse.java new file mode 100644 index 00000000..5c917108 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/job/dto/response/GetJobResponse.java @@ -0,0 +1,40 @@ +package com.graphy.backend.domain.job.dto.response; + +import com.graphy.backend.domain.job.domain.Job; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetJobResponse { + + private Long id; + + private String companyName; + + private String title; + + private String url; + + private LocalDateTime expirationDate; + + public static GetJobResponse from(Job job) { + return GetJobResponse.builder() + .id(job.getId()) + .companyName(job.getCompanyName()) + .title(job.getTitle()) + .url(job.getUrl()) + .expirationDate(job.getExpirationDate()) + .build(); + } + + public static Page listOf(Page jobs) { + return jobs.map(GetJobResponse::from); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/job/repository/JobRepository.java b/backend/src/main/java/com/graphy/backend/domain/job/repository/JobRepository.java index c6491137..d4ecbfa2 100644 --- a/backend/src/main/java/com/graphy/backend/domain/job/repository/JobRepository.java +++ b/backend/src/main/java/com/graphy/backend/domain/job/repository/JobRepository.java @@ -1,14 +1,12 @@ package com.graphy.backend.domain.job.repository; import com.graphy.backend.domain.job.domain.Job; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; - -import java.time.LocalDateTime; public interface JobRepository extends JpaRepository { - @Modifying - @Query("DELETE FROM Job j WHERE j.expirationDate < :today") - void deleteAllExpiredSince(LocalDateTime today); + + @Override + Page findAll(Pageable pageable); } diff --git a/backend/src/main/java/com/graphy/backend/domain/job/service/JobService.java b/backend/src/main/java/com/graphy/backend/domain/job/service/JobService.java index 6b7e45f5..5168ff55 100644 --- a/backend/src/main/java/com/graphy/backend/domain/job/service/JobService.java +++ b/backend/src/main/java/com/graphy/backend/domain/job/service/JobService.java @@ -1,106 +1,23 @@ package com.graphy.backend.domain.job.service; import com.graphy.backend.domain.job.domain.Job; -import com.graphy.backend.domain.job.dto.JobDto; +import com.graphy.backend.domain.job.dto.response.GetJobResponse; import com.graphy.backend.domain.job.repository.JobRepository; +import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import org.json.JSONObject; -import org.json.JSONArray; import org.springframework.transaction.annotation.Transactional; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; - - @Service @Transactional @RequiredArgsConstructor public class JobService { - @Value("${job.key}") - String accessKey; private final JobRepository jobRepository; - - public void save() { - StringBuffer response = new StringBuffer(); - - try { - String text = URLEncoder.encode("", "UTF-8"); - String apiURL = "https://oapi.saramin.co.kr/job-search?access-key=" + accessKey + "&bbs_gb=0&job_type=&edu_lv=&job_mid_cd=2"; - - URL url = new URL(apiURL); - HttpURLConnection con = (HttpURLConnection) url.openConnection(); - con.setRequestMethod("GET"); - con.setRequestProperty("Accept", "application/json"); - - int responseCode = con.getResponseCode(); - BufferedReader br; - if (responseCode == 200) { // 정상 호출 - br = new BufferedReader(new InputStreamReader(con.getInputStream())); - } else { // 에러 발생 - br = new BufferedReader(new InputStreamReader(con.getErrorStream())); - } - - String inputLine; - while ((inputLine = br.readLine()) != null) { - response.append(inputLine); - } - br.close(); - } catch (Exception e) { - System.out.println(e); - } - - saveJobInfo(response.toString()); - System.out.println(response); - } - - private void saveJobInfo(String response) { - - JSONObject jsonObject = new JSONObject(response); - - JSONArray jobsArray = jsonObject.getJSONObject("jobs").getJSONArray("job"); - - for (int i = 0; i < jobsArray.length(); i++) { - JSONObject jobObject = jobsArray.getJSONObject(i); - - // 공고 ID - Long jobId = jobObject.getLong("id"); - - // 회사 이름 - String companyName = jobObject.getJSONObject("company") - .getJSONObject("detail") - .getString("name"); - - // 공고 제목 - String jobTitle = jobObject.getJSONObject("position") - .getString("title"); - - // URL - String companyInfoURL = jobObject.getJSONObject("company") - .getJSONObject("detail") - .getString("href"); - - // 만료일 - long expirationTimestampLong = jobObject.getLong("expiration-timestamp"); - LocalDateTime expirationTimestamp = LocalDateTime.ofInstant( - Instant.ofEpochSecond(expirationTimestampLong), - ZoneId.systemDefault()); - - JobDto.CreateJobInfoRequest dto = new JobDto.CreateJobInfoRequest(jobId, companyName, jobTitle, companyInfoURL, expirationTimestamp); - jobRepository.save(dto.toEntity()); - } - } - - - public void deleteExpiredJobs() { - jobRepository.deleteAllExpiredSince(LocalDateTime.now()); + public List findJobList(Pageable pageable) { + Page jobs = jobRepository.findAll(pageable); + return GetJobResponse.listOf(jobs).getContent(); } } diff --git a/backend/src/main/java/com/graphy/backend/domain/member/controller/MemberController.java b/backend/src/main/java/com/graphy/backend/domain/member/controller/MemberController.java index 60f8ab88..17a124b5 100644 --- a/backend/src/main/java/com/graphy/backend/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/graphy/backend/domain/member/controller/MemberController.java @@ -1,10 +1,11 @@ package com.graphy.backend.domain.member.controller; +import com.graphy.backend.domain.auth.util.annotation.CurrentUser; import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.member.dto.response.GetMemberResponse; import com.graphy.backend.domain.member.dto.response.GetMyPageResponse; import com.graphy.backend.domain.member.service.MemberService; -import com.graphy.backend.domain.auth.util.annotation.CurrentUser; +import com.graphy.backend.domain.project.service.ProjectService; import com.graphy.backend.global.result.ResultCode; import com.graphy.backend.global.result.ResultResponse; import io.swagger.v3.oas.annotations.Operation; @@ -23,6 +24,7 @@ @RequiredArgsConstructor(access = AccessLevel.PROTECTED) public class MemberController { private final MemberService memberService; + private final ProjectService projectService; @Operation(summary = "get member", description = "사용자 조회") @GetMapping() @@ -34,7 +36,14 @@ public ResponseEntity memberList(@RequestParam String nickname) @Operation(summary = "myPage", description = "마이페이지") @GetMapping("/mypage") public ResponseEntity myPage(@CurrentUser Member member) { - GetMyPageResponse result = memberService.myPage(member); + GetMyPageResponse result = projectService.myPage(member); + return ResponseEntity.ok(ResultResponse.of(ResultCode.MYPAGE_GET_SUCCESS, result)); + } + + @Operation(summary = "get myPage by nickname", description = "사용자 닉네임으로 마이페이지 조회") + @GetMapping("/mypage/{nickname}") + public ResponseEntity mypageByNickname(@PathVariable String nickname) { + GetMyPageResponse result = projectService.myPageByNickname(nickname); return ResponseEntity.ok(ResultResponse.of(ResultCode.MYPAGE_GET_SUCCESS, result)); } } diff --git a/backend/src/main/java/com/graphy/backend/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/graphy/backend/domain/member/repository/MemberRepository.java index 6d74d269..41161cd1 100644 --- a/backend/src/main/java/com/graphy/backend/domain/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/graphy/backend/domain/member/repository/MemberRepository.java @@ -2,8 +2,6 @@ import com.graphy.backend.domain.member.domain.Member; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; @@ -11,4 +9,6 @@ public interface MemberRepository extends JpaRepository, MemberCustomRepository { Optional findByEmail(String email); List findMemberByNicknameStartingWith(String nickname); + + Optional findFirstByNickname(String nickname); } diff --git a/backend/src/main/java/com/graphy/backend/domain/member/service/MemberService.java b/backend/src/main/java/com/graphy/backend/domain/member/service/MemberService.java index 645263e4..31f64e45 100644 --- a/backend/src/main/java/com/graphy/backend/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/graphy/backend/domain/member/service/MemberService.java @@ -2,10 +2,7 @@ import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.member.dto.response.GetMemberResponse; -import com.graphy.backend.domain.member.dto.response.GetMyPageResponse; import com.graphy.backend.domain.member.repository.MemberRepository; -import com.graphy.backend.domain.project.dto.response.GetProjectInfoResponse; -import com.graphy.backend.domain.project.service.ProjectService; import com.graphy.backend.global.error.ErrorCode; import com.graphy.backend.global.error.exception.AlreadyExistException; import com.graphy.backend.global.error.exception.EmptyResultException; @@ -15,7 +12,6 @@ import javax.transaction.Transactional; import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; import static com.graphy.backend.global.error.ErrorCode.MEMBER_NOT_EXIST; @@ -25,7 +21,6 @@ @Transactional public class MemberService { private final MemberRepository memberRepository; - private final ProjectService projectService; public List findMemberList(String nickname) { @@ -40,11 +35,6 @@ public Member findMemberById(Long id) { -> new EmptyResultException(MEMBER_NOT_EXIST)); } - public GetMyPageResponse myPage(Member member) { - List projectInfoList = projectService.findProjectInfoList(member.getId()); - return GetMyPageResponse.of(member, projectInfoList); - } - public void checkEmailDuplicate(String email) { if (memberRepository.findByEmail(email).isPresent()) throw new AlreadyExistException(ErrorCode.MEMBER_ALREADY_EXIST); @@ -59,4 +49,11 @@ public Member findMemberByEmail(String email) { () -> new EmptyResultException(MEMBER_NOT_EXIST) ); } + + public Member findMemberByNickname(String nickname) { + return memberRepository.findFirstByNickname(nickname).orElseThrow( + () -> new EmptyResultException(MEMBER_NOT_EXIST) + ); + } + } diff --git a/backend/src/main/java/com/graphy/backend/domain/message/controller/MessageController.java b/backend/src/main/java/com/graphy/backend/domain/message/controller/MessageController.java new file mode 100644 index 00000000..14f1b0d4 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/controller/MessageController.java @@ -0,0 +1,54 @@ +package com.graphy.backend.domain.message.controller; + +import com.graphy.backend.domain.auth.util.annotation.CurrentUser; +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.message.dto.request.CreateMessageRequest; +import com.graphy.backend.domain.message.dto.response.GetMessageDetailResponse; +import com.graphy.backend.domain.message.dto.response.GetMessageResponse; +import com.graphy.backend.domain.message.service.MessageService; +import com.graphy.backend.global.common.PageRequest; +import com.graphy.backend.global.result.ResultCode; +import com.graphy.backend.global.result.ResultResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "MessageController", description = "쪽지 관련 API") +@RestController +@RequestMapping("api/v1/messages") +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class MessageController { + private final MessageService messageService; + @Operation(summary = "createMessage", description = "쪽지 보내기") + @PostMapping + public ResponseEntity messageAdd(@Validated @RequestBody CreateMessageRequest request, + @CurrentUser Member loginUser) { + messageService.addMessage(request, loginUser); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ResultResponse.of(ResultCode.MESSAGE_CREATE_SUCCESS)); + } + + @Operation(summary = "findMessage", description = "쪽지 상세 조회") + @GetMapping("/{messageId}") + public ResponseEntity messageDetails(@PathVariable Long messageId) { + GetMessageDetailResponse result = messageService.findMessageById(messageId); + return ResponseEntity.ok(ResultResponse.of(ResultCode.MESSAGE_GET_SUCCESS, result)); + } + + @Operation(summary = "findMessageList", description = "쪽지 목록 조회") + @GetMapping + public ResponseEntity messageList(@CurrentUser Member loginUser, + PageRequest pageRequest) { + Pageable pageable = pageRequest.of(); + List result = messageService.findMessageList(loginUser, pageable); + return ResponseEntity.ok(ResultResponse.of(ResultCode.MESSAGE_PAGING_GET_SUCCESS, result)); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/domain/Message.java b/backend/src/main/java/com/graphy/backend/domain/message/domain/Message.java new file mode 100644 index 00000000..df804b45 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/domain/Message.java @@ -0,0 +1,33 @@ +package com.graphy.backend.domain.message.domain; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.global.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Message extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private Member sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private Member receiver; + + @Lob + @Column(nullable = false) + private String content; +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/dto/request/CreateMessageRequest.java b/backend/src/main/java/com/graphy/backend/domain/message/dto/request/CreateMessageRequest.java new file mode 100644 index 00000000..3803bc24 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/dto/request/CreateMessageRequest.java @@ -0,0 +1,31 @@ +package com.graphy.backend.domain.message.dto.request; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.message.domain.Message; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateMessageRequest { + @NotBlank(message = "content cannot be blank") + private String content; + + @NotNull(message = "recruitmentId cannot be null") + private Long toMemberId; + + public Message toEntity(Member fromMember, Member toMember) { + return Message.builder() + .sender(fromMember) + .receiver(toMember) + .content(content) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/dto/response/GetMessageDetailResponse.java b/backend/src/main/java/com/graphy/backend/domain/message/dto/response/GetMessageDetailResponse.java new file mode 100644 index 00000000..44ecb99f --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/dto/response/GetMessageDetailResponse.java @@ -0,0 +1,29 @@ +package com.graphy.backend.domain.message.dto.response; + +import com.graphy.backend.domain.message.domain.Message; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetMessageDetailResponse { + private Long senderId; + private String memberNickname; + private String content; + private LocalDateTime sentAt; + + public static GetMessageDetailResponse from(Message message) { + return GetMessageDetailResponse.builder() + .senderId(message.getSender().getId()) + .memberNickname(message.getSender().getNickname()) + .content(message.getContent()) + .sentAt(message.getCreatedAt()) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/dto/response/GetMessageResponse.java b/backend/src/main/java/com/graphy/backend/domain/message/dto/response/GetMessageResponse.java new file mode 100644 index 00000000..ebabc2dd --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/dto/response/GetMessageResponse.java @@ -0,0 +1,26 @@ +package com.graphy.backend.domain.message.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +public class GetMessageResponse { + private Long senderId; + private String memberNickname; + private String content; + private LocalDateTime sentAt; + + @QueryProjection + public GetMessageResponse(Long senderId, String memberNickname, String content, LocalDateTime sentAt) { + this.senderId = senderId; + this.memberNickname = memberNickname; + this.content = content; + this.sentAt = sentAt; + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/repository/MessageCustomRepository.java b/backend/src/main/java/com/graphy/backend/domain/message/repository/MessageCustomRepository.java new file mode 100644 index 00000000..d549d44a --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/repository/MessageCustomRepository.java @@ -0,0 +1,11 @@ +package com.graphy.backend.domain.message.repository; + +import com.graphy.backend.domain.message.dto.response.GetMessageResponse; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface MessageCustomRepository { + List findMessages(Pageable pageable, Long memberId); + +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/repository/MessageRepository.java b/backend/src/main/java/com/graphy/backend/domain/message/repository/MessageRepository.java new file mode 100644 index 00000000..77830831 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/repository/MessageRepository.java @@ -0,0 +1,8 @@ +package com.graphy.backend.domain.message.repository; + +import com.graphy.backend.domain.message.domain.Message; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MessageRepository extends JpaRepository, MessageCustomRepository { + +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/repository/custom/MessageCustomRepositoryImpl.java b/backend/src/main/java/com/graphy/backend/domain/message/repository/custom/MessageCustomRepositoryImpl.java new file mode 100644 index 00000000..57775bf0 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/repository/custom/MessageCustomRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.graphy.backend.domain.message.repository.custom; + +import com.graphy.backend.domain.message.dto.response.GetMessageResponse; +import com.graphy.backend.domain.message.dto.response.QGetMessageResponse; +import com.graphy.backend.domain.message.repository.MessageCustomRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static com.graphy.backend.domain.member.domain.QMember.member; +import static com.graphy.backend.domain.message.domain.QMessage.message; + +@RequiredArgsConstructor +public class MessageCustomRepositoryImpl implements MessageCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findMessages(Pageable pageable, Long memberId) { + return jpaQueryFactory + .select(new QGetMessageResponse( + member.id, + member.nickname, + message.content, + message.createdAt + )) + .from(message) + .join(message.sender, member) + .where(message.receiver.id.eq(memberId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + +} diff --git a/backend/src/main/java/com/graphy/backend/domain/message/service/MessageService.java b/backend/src/main/java/com/graphy/backend/domain/message/service/MessageService.java new file mode 100644 index 00000000..d73e4fe6 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/message/service/MessageService.java @@ -0,0 +1,58 @@ +package com.graphy.backend.domain.message.service; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.member.service.MemberService; +import com.graphy.backend.domain.message.domain.Message; +import com.graphy.backend.domain.message.dto.request.CreateMessageRequest; +import com.graphy.backend.domain.message.dto.response.GetMessageDetailResponse; +import com.graphy.backend.domain.message.dto.response.GetMessageResponse; +import com.graphy.backend.domain.message.repository.MessageRepository; +import com.graphy.backend.domain.notification.domain.NotificationType; +import com.graphy.backend.domain.notification.dto.NotificationDto; +import com.graphy.backend.domain.notification.service.NotificationService; +import com.graphy.backend.global.error.ErrorCode; +import com.graphy.backend.global.error.exception.EmptyResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MessageService { + private final MessageRepository messageRepository; + private final MemberService memberService; + private final NotificationService notificationService; + + @Transactional + public void addMessage(CreateMessageRequest request, Member loginUser) { + Member receiver = memberService.findMemberById(request.getToMemberId()); + messageRepository.save(request.toEntity(loginUser, receiver)); + + NotificationType notificationType = NotificationType.MESSAGE; + notificationType.setMessage(loginUser.getNickname(), ""); + NotificationDto notificationDto = NotificationDto.builder() + .type(notificationType) + .content(notificationType.getMessage()) + .build(); + + notificationService.addNotification(notificationDto, receiver.getId()); + } + + public GetMessageDetailResponse findMessageById(Long messageId) { + Message message = messageRepository.findById(messageId).orElseThrow( + () -> new EmptyResultException(ErrorCode.MESSAGE_NOT_EXIST) + ); + return GetMessageDetailResponse.from(message); + } + + public List findMessageList(Member loginUser, Pageable pageable) { + List messages = messageRepository.findMessages(pageable, loginUser.getId()); + + if (messages.isEmpty()) throw new EmptyResultException(ErrorCode.MESSAGE_NOT_EXIST); + else return messages; + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/controller/NotificationController.java b/backend/src/main/java/com/graphy/backend/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..695f0232 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/controller/NotificationController.java @@ -0,0 +1,34 @@ +package com.graphy.backend.domain.notification.controller; + +import com.graphy.backend.domain.auth.util.annotation.CurrentUser; +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.notification.dto.response.GetNotificationResponse; +import com.graphy.backend.domain.notification.service.NotificationService; +import com.graphy.backend.global.common.PageRequest; +import com.graphy.backend.global.result.ResultCode; +import com.graphy.backend.global.result.ResultResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "NotificationController", description = "알림 관련 API") +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "findNotificationList", description = "알림 목록 조회") + @GetMapping + public ResponseEntity notificationList(PageRequest pageRequest, @CurrentUser Member loginUser) { + List result = notificationService.findNotificationList(pageRequest, loginUser); + return ResponseEntity.ok(ResultResponse.of(ResultCode.NOTIFICATION_PAGING_GET_SUCCESS, result)); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/domain/Notification.java b/backend/src/main/java/com/graphy/backend/domain/notification/domain/Notification.java new file mode 100644 index 00000000..1b62b649 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/domain/Notification.java @@ -0,0 +1,41 @@ +package com.graphy.backend.domain.notification.domain; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.global.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import javax.persistence.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Where(clause = "is_deleted = false") +@SQLDelete(sql = "UPDATE notification SET is_deleted = true WHERE notification_id = ?") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private boolean isRead; +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/domain/NotificationType.java b/backend/src/main/java/com/graphy/backend/domain/notification/domain/NotificationType.java new file mode 100644 index 00000000..bda4a2b8 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/domain/NotificationType.java @@ -0,0 +1,18 @@ +package com.graphy.backend.domain.notification.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum NotificationType { + RECRUITMENT("님이 팀 합류를 "), // 팀원 모집 글 관련 알림 (지원 신청, 지원 신청 수락) + FOLLOW("님이 팔로우하였습니다."), // 팔로우 관련 알림(본인을 팔로우하는 사용자가 발생 시) + MESSAGE("님이 쪽지를 보냈습니다."); // 쪽지 알림 (쪽지 수신 시) + + private String message; + + public void setMessage(String username, String extraMessage) { + this.message = username + message +extraMessage; + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/dto/NotificationDto.java b/backend/src/main/java/com/graphy/backend/domain/notification/dto/NotificationDto.java new file mode 100644 index 00000000..3fc3279f --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/dto/NotificationDto.java @@ -0,0 +1,31 @@ +package com.graphy.backend.domain.notification.dto; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.notification.domain.Notification; +import com.graphy.backend.domain.notification.domain.NotificationType; +import lombok.*; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationDto { + private NotificationType type; + private Member member; + private String content; + private boolean isRead = false; + + public void setMember(Member member) { + this.member = member; + } + + public Notification toEntity() { + return Notification.builder() + .type(type) + .member(member) + .content(content) + .isRead(this.isRead) + .build(); + } +} + diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/dto/response/GetNotificationResponse.java b/backend/src/main/java/com/graphy/backend/domain/notification/dto/response/GetNotificationResponse.java new file mode 100644 index 00000000..e8b3c5be --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/dto/response/GetNotificationResponse.java @@ -0,0 +1,36 @@ +package com.graphy.backend.domain.notification.dto.response; + +import com.graphy.backend.domain.notification.domain.Notification; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetNotificationResponse { + private Long id; + private String type; + private String content; + private boolean isRead; + + public static GetNotificationResponse from(Notification notification) { + return GetNotificationResponse.builder() + .id(notification.getId()) + .type(notification.getType().toString()) + .content(notification.getContent()) + .isRead(notification.isRead()) + .build(); + } + + public static List from(List notifications) { + return notifications.stream() + .map(GetNotificationResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/repository/NotificationRepository.java b/backend/src/main/java/com/graphy/backend/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..620e7c64 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,10 @@ +package com.graphy.backend.domain.notification.repository; + +import com.graphy.backend.domain.notification.domain.Notification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + Page findAllByMemberId(Long memberId, Pageable pageable); +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/service/MailingService.java b/backend/src/main/java/com/graphy/backend/domain/notification/service/MailingService.java new file mode 100644 index 00000000..5498cabe --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/service/MailingService.java @@ -0,0 +1,52 @@ +package com.graphy.backend.domain.notification.service; + +import com.graphy.backend.domain.notification.domain.Notification; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring5.SpringTemplateEngine; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class MailingService { + + private final JavaMailSender javaMailSender; + private final SpringTemplateEngine templateEngine; + + private static final String EMAIL_TITLE_PREFIX = "[Graphy] "; + + @Async + public void sendNotificationEmail(Notification notification) throws MessagingException { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper messageHelper = new MimeMessageHelper(message, true, "UTF-8"); + + messageHelper.setSubject(EMAIL_TITLE_PREFIX + notification.getContent()); + messageHelper.setTo(notification.getMember().getEmail()); + + HashMap emailValues = new HashMap<>(); + emailValues.put("content", notification.getContent()); + String text = setContext(emailValues); + + messageHelper.setText(text, true); + + messageHelper.addInline("logo", new ClassPathResource("static/images/image-2.png")); + messageHelper.addInline("notice-icon", new ClassPathResource("static/images/image-1.png")); + + javaMailSender.send(message); + } + + private String setContext(Map emailValues) { + Context context = new Context(); + emailValues.forEach(context::setVariable); + return templateEngine.process("email/index", context); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/notification/service/NotificationService.java b/backend/src/main/java/com/graphy/backend/domain/notification/service/NotificationService.java new file mode 100644 index 00000000..12aa204d --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/notification/service/NotificationService.java @@ -0,0 +1,49 @@ +package com.graphy.backend.domain.notification.service; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.member.service.MemberService; +import com.graphy.backend.domain.notification.domain.Notification; +import com.graphy.backend.domain.notification.dto.NotificationDto; +import com.graphy.backend.domain.notification.dto.response.GetNotificationResponse; +import com.graphy.backend.domain.notification.repository.NotificationRepository; +import com.graphy.backend.global.common.PageRequest; +import com.graphy.backend.global.error.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.mail.MessagingException; + +import java.util.List; + +import static com.graphy.backend.global.error.ErrorCode.SEND_EMAIL_FAIL; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class NotificationService { + private final NotificationRepository notificationRepository; + private final MemberService memberService; + private final MailingService mailingService; + + @Transactional + public void addNotification(NotificationDto dto, Long memberId) { + Member member = memberService.findMemberById(memberId); + dto.setMember(member); + Notification entity = notificationRepository.save(dto.toEntity()); + try { + mailingService.sendNotificationEmail(entity); + } catch (MessagingException e) { + throw new BusinessException(SEND_EMAIL_FAIL); + } + } + + public List findNotificationList(PageRequest pageRequest, Member loginUser) { + memberService.findMemberById(loginUser.getId()); + Page result + = notificationRepository.findAllByMemberId(loginUser.getId(), pageRequest.of()); + List notifications = result.getContent(); + return GetNotificationResponse.from(notifications); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java b/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java index b82f3cbf..a4b129e1 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java @@ -74,6 +74,16 @@ public ResponseEntity projectList(GetProjectsRequest dto, PageRe return ResponseEntity.ok(ResultResponse.of(ResultCode.PROJECT_PAGING_GET_SUCCESS, result)); } + @Operation(summary = "findFollowingProjectList", description = "팔로잉하고 있는 사용자의 프로젝트 조회") + @GetMapping("/following") + public ResponseEntity projectList(PageRequest pageRequest, @CurrentUser Member loginUser){ + Pageable pageable = pageRequest.of(); + List result = projectService.findFollowingProjectList(loginUser, pageable); + + return ResponseEntity.ok(ResultResponse.of(ResultCode.PROJECT_PAGING_GET_SUCCESS, result)); + } + + @Operation(summary = "findProject", description = "프로젝트 상세 조회") @GetMapping("/{projectId}") public ResponseEntity projectDetails(@PathVariable Long projectId) { @@ -88,9 +98,7 @@ public ResponseEntity projectPlanDetails(final @RequestBody GetP projectService.checkGptRequestToken(prompt); CompletableFuture futureResult = - projectService.getProjectPlanAsync(prompt).thenApply(result -> { - return result; - }); + projectService.getProjectPlanAsync(prompt).thenApply(result -> result); String response = futureResult.get(); return ResponseEntity.ok(ResultResponse.of(ResultCode.PLAN_CREATE_SUCCESS, response)); diff --git a/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java b/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java index 81178b2d..6f591d43 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java @@ -3,7 +3,10 @@ import com.graphy.backend.domain.comment.domain.Comment; import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.global.common.BaseEntity; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; diff --git a/backend/src/main/java/com/graphy/backend/domain/project/domain/ProjectTag.java b/backend/src/main/java/com/graphy/backend/domain/project/domain/ProjectTag.java index 31b1ae08..7309170b 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/domain/ProjectTag.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/domain/ProjectTag.java @@ -1,6 +1,9 @@ package com.graphy.backend.domain.project.domain; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import javax.persistence.*; @@ -20,7 +23,6 @@ public class ProjectTag { @JoinColumn(name = "project_id") private Project project; - @ManyToOne @JoinColumn(name = "tag_id") private Tag tag; diff --git a/backend/src/main/java/com/graphy/backend/domain/project/domain/Tag.java b/backend/src/main/java/com/graphy/backend/domain/project/domain/Tag.java index f676f220..6e3c4fbf 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/domain/Tag.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/domain/Tag.java @@ -1,7 +1,11 @@ package com.graphy.backend.domain.project.domain; -import lombok.*; +import com.graphy.backend.domain.recruitment.domain.RecruitmentTag; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import javax.persistence.*; import java.util.Set; @@ -23,4 +27,6 @@ public class Tag { @OneToMany(mappedBy = "tag", cascade = CascadeType.ALL) private Set projects; + @OneToMany(mappedBy = "tag", cascade = CascadeType.ALL) + private Set recruitments; } \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/domain/project/dto/request/GetProjectsRequest.java b/backend/src/main/java/com/graphy/backend/domain/project/dto/request/GetProjectsRequest.java index 4c9d554e..93d750c1 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/dto/request/GetProjectsRequest.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/dto/request/GetProjectsRequest.java @@ -1,11 +1,9 @@ package com.graphy.backend.domain.project.dto.request; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectInfoResponse.java b/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectInfoResponse.java index 2f8795e2..ec4de5f4 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectInfoResponse.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectInfoResponse.java @@ -1,7 +1,10 @@ package com.graphy.backend.domain.project.dto.response; import com.graphy.backend.domain.project.domain.Project; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder @@ -13,13 +16,13 @@ public class GetProjectInfoResponse { private String projectName; - private String description; + private String content; public static GetProjectInfoResponse from(Project project) { return GetProjectInfoResponse.builder() .id(project.getId()) .projectName(project.getProjectName()) - .description(project.getDescription()) + .content(project.getContent()) .build(); } } \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/domain/project/repository/ProjectCustomRepository.java b/backend/src/main/java/com/graphy/backend/domain/project/repository/ProjectCustomRepository.java index 9567a54f..77c7d2f0 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/repository/ProjectCustomRepository.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/repository/ProjectCustomRepository.java @@ -6,4 +6,6 @@ public interface ProjectCustomRepository { Page searchProjectsWith(Pageable pageable, String projectName, String content); + + Page findFollowingProjects(Pageable pageable, Long fromId); } diff --git a/backend/src/main/java/com/graphy/backend/domain/project/repository/custom/ProjectCustomRepositoryImpl.java b/backend/src/main/java/com/graphy/backend/domain/project/repository/custom/ProjectCustomRepositoryImpl.java index ae45182d..efd68ae8 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/repository/custom/ProjectCustomRepositoryImpl.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/repository/custom/ProjectCustomRepositoryImpl.java @@ -4,31 +4,34 @@ import com.graphy.backend.domain.project.repository.ProjectCustomRepository; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; - import java.util.List; +import static com.graphy.backend.domain.follow.domain.QFollow.follow; import static com.graphy.backend.domain.project.domain.QProject.project; +import static com.querydsl.jpa.JPAExpressions.select; + @RequiredArgsConstructor public class ProjectCustomRepositoryImpl implements ProjectCustomRepository { - private final JPAQueryFactory queryFactory; + private final JPAQueryFactory jpaQueryFactory; @Override public Page searchProjectsWith(Pageable pageable, String projectName, String content) { - List fetch = queryFactory + List fetch = jpaQueryFactory .selectFrom(project).where(projectNameLike(projectName), contentLike(content)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); - JPQLQuery count = queryFactory + JPQLQuery count = jpaQueryFactory .select(project) .from(project) .where(projectNameLike(projectName), contentLike(content)); @@ -36,11 +39,29 @@ public Page searchProjectsWith(Pageable pageable, String projectName, S return PageableExecutionUtils.getPage(fetch, pageable, count::fetchCount); } + @Override + public Page findFollowingProjects(Pageable pageable, Long fromId) { + List fetch = jpaQueryFactory.selectFrom(project) + .where(project.member.id.in( + select(follow.toId).from(follow).where(follow.fromId.eq(fromId) + ))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + JPAQuery count = jpaQueryFactory + .select(project.count()) + .from(project) + .where(project.member.id.in( + select(follow.toId).from(follow).where(follow.fromId.eq(fromId) + ))); + return PageableExecutionUtils.getPage(fetch, pageable, count::fetchOne); + } + private BooleanExpression projectNameLike(String projectName) { - return projectName != null ? project.projectName.eq(projectName) : null; + return projectName != null ? project.projectName.contains(projectName) : null; } private BooleanExpression contentLike(String content) { - return content != null ? project.content.eq(content) : null; + return content != null ? project.content.contains(content) : null; } } \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java b/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java index 8bee84e5..4297239d 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java @@ -3,8 +3,9 @@ import com.graphy.backend.domain.comment.dto.response.GetCommentWithMaskingResponse; import com.graphy.backend.domain.comment.service.CommentService; import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.member.dto.response.GetMyPageResponse; +import com.graphy.backend.domain.member.service.MemberService; import com.graphy.backend.domain.project.domain.Project; -import com.graphy.backend.domain.project.domain.Tag; import com.graphy.backend.domain.project.domain.Tags; import com.graphy.backend.domain.project.dto.request.CreateProjectRequest; import com.graphy.backend.domain.project.dto.request.GetProjectPlanRequest; @@ -22,7 +23,6 @@ import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ClassPathResource; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -30,10 +30,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import javax.annotation.PostConstruct; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -46,12 +42,12 @@ public class ProjectService { private final ProjectRepository projectRepository; + private final MemberService memberService; private final ProjectTagService projectTagService; - private final TagRepository tagRepository; private final CommentService commentService; private final TagService tagService; - private final GPTChatRestService gptChatRestService; + private final TagRepository tagRepository; // @PostConstruct // public void initTag() throws IOException { @@ -72,7 +68,7 @@ public class ProjectService { public CreateProjectResponse addProject(CreateProjectRequest dto, Member loginUser) { Project entity = dto.toEntity(loginUser); if (dto.getTechTags() != null) { - Tags foundTags = findTagListByName(dto.getTechTags()); + Tags foundTags = tagService.findTagListByName(dto.getTechTags()); entity.addTag(foundTags); } Project project = projectRepository.save(entity); @@ -92,7 +88,7 @@ public void removeProject(Long projectId) { public UpdateProjectResponse modifyProject(Long projectId, UpdateProjectRequest dto) { Project project = projectRepository.findById(projectId).get(); projectTagService.removeProjectTag(project.getId()); - Tags updatedTags = findTagListByName(dto.getTechTags()); + Tags updatedTags = tagService.findTagListByName(dto.getTechTags()); project.updateProject(dto.getProjectName(), dto.getContent(), dto.getDescription(), updatedTags, dto.getThumbNail()); @@ -108,23 +104,35 @@ public GetProjectDetailResponse findProjectById(Long projectId) { return GetProjectDetailResponse.of(project, comments); } - public Tags findTagListByName(List techStacks) { - List foundTags = techStacks.stream().map(tagService::findTagByTech) - .collect(Collectors.toList()); - return new Tags(foundTags); - } - public List findProjectList(GetProjectsRequest dto, Pageable pageable) { Page projects = projectRepository.searchProjectsWith(pageable, dto.getProjectName(), dto.getContent()); return GetProjectResponse.listOf(projects).getContent(); } + public List findFollowingProjectList(Member loginUser, Pageable pageable) { + Member member = memberService.findMemberById(loginUser.getId()); + Page projects = projectRepository.findFollowingProjects(pageable, member.getId()); + return GetProjectResponse.listOf(projects).getContent(); + } + public List findProjectInfoList(Long id) { return projectRepository.findByMemberId(id).stream() .map(GetProjectInfoResponse::from) .collect(Collectors.toList()); } + public GetMyPageResponse myPage(Member member) { + List projectInfoList = this.findProjectInfoList(member.getId()); + return GetMyPageResponse.of(member, projectInfoList); + } + + public GetMyPageResponse myPageByNickname(String nickname) { + Member member = memberService.findMemberByNickname(nickname); + List projectInfoList = this.findProjectInfoList(member.getId()); + return GetMyPageResponse.of(member, projectInfoList); + } + + public Project getProjectById(Long id) { return projectRepository.findById(id).orElseThrow(() -> new EmptyResultException(ErrorCode.PROJECT_DELETED_OR_NOT_EXIST)); } diff --git a/backend/src/main/java/com/graphy/backend/domain/project/service/TagService.java b/backend/src/main/java/com/graphy/backend/domain/project/service/TagService.java index bd361c2b..7a0929b3 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/service/TagService.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/service/TagService.java @@ -1,17 +1,28 @@ package com.graphy.backend.domain.project.service; import com.graphy.backend.domain.project.domain.Tag; +import com.graphy.backend.domain.project.domain.Tags; import com.graphy.backend.domain.project.repository.TagRepository; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor(access = AccessLevel.PROTECTED) public class TagService { private final TagRepository tagRepository; + public Tags findTagListByName(List techStacks) { + List foundTags = techStacks.stream() + .map(this::findTagByTech) + .collect(Collectors.toList()); + return new Tags(foundTags); + } + public Tag findTagByTech(String tech) { return tagRepository.findTagByTech(tech); } diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/controller/ApplicationController.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/controller/ApplicationController.java new file mode 100644 index 00000000..3d8bd785 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/controller/ApplicationController.java @@ -0,0 +1,41 @@ +package com.graphy.backend.domain.recruitment.controller; + +import com.graphy.backend.domain.auth.util.annotation.CurrentUser; +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.recruitment.dto.request.CreateApplicationRequest; +import com.graphy.backend.domain.recruitment.dto.response.GetApplicationDetailResponse; +import com.graphy.backend.domain.recruitment.service.ApplicationService; +import com.graphy.backend.global.result.ResultCode; +import com.graphy.backend.global.result.ResultResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "ApplicationController", description = "프로젝트 구인 게시글 신청 관련 API") +@RestController +@RequestMapping("api/v1/applications") +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationController { + private final ApplicationService applicationService; + + @Operation(summary = "createApplication", description = "프로젝트 참가 신청") + @PostMapping + public ResponseEntity applicationAdd(@Validated @RequestBody CreateApplicationRequest request, + @CurrentUser Member loginUser) { + applicationService.addApplication(request, loginUser); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ResultResponse.of(ResultCode.APPLICATION_CREATE_SUCCESS)); + } + + @Operation(summary = "findApplication", description = "프로젝트 참가 신청서 상세 조회") + @GetMapping("/{applicationId}") + public ResponseEntity applicationDetails(@PathVariable Long applicationId) { + GetApplicationDetailResponse result = applicationService.findApplicationById(applicationId); + return ResponseEntity.ok(ResultResponse.of(ResultCode.APPLICATION_GET_SUCCESS, result)); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/controller/RecruitmentController.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/controller/RecruitmentController.java new file mode 100644 index 00000000..8090b454 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/controller/RecruitmentController.java @@ -0,0 +1,90 @@ +package com.graphy.backend.domain.recruitment.controller; + +import com.graphy.backend.domain.auth.util.annotation.CurrentUser; +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.dto.request.CreateRecruitmentRequest; +import com.graphy.backend.domain.recruitment.dto.request.UpdateRecruitmentRequest; +import com.graphy.backend.domain.recruitment.dto.response.GetApplicationResponse; +import com.graphy.backend.domain.recruitment.dto.response.GetRecruitmentDetailResponse; +import com.graphy.backend.domain.recruitment.dto.response.GetRecruitmentResponse; +import com.graphy.backend.domain.recruitment.service.RecruitmentService; +import com.graphy.backend.global.common.PageRequest; +import com.graphy.backend.global.result.ResultCode; +import com.graphy.backend.global.result.ResultResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "RecruitmentController", description = "프로젝트 구인 게시글 관련 API") +@RestController +@RequestMapping("api/v1/recruitments") +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class RecruitmentController { + private final RecruitmentService recruitmentService; + + @Operation(summary = "createRecruitment", description = "구인 게시글 생성") + @PostMapping + public ResponseEntity recruitmentAdd(@Validated @RequestBody CreateRecruitmentRequest request, + @CurrentUser Member loginUser) { + recruitmentService.addRecruitment(request, loginUser); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ResultResponse.of(ResultCode.RECRUITMENT_CREATE_SUCCESS)); + } + + @Operation(summary = "findRecruitment", description = "구인 게시글 상세 조회") + @GetMapping("/{recruitmentId}") + public ResponseEntity recruitmentDetails(@PathVariable Long recruitmentId) { + GetRecruitmentDetailResponse result = recruitmentService.findRecruitmentById(recruitmentId); + return ResponseEntity.ok(ResultResponse.of(ResultCode.RECRUITMENT_GET_SUCCESS, result)); + } + + @Operation(summary = "findRecruitmentList", description = "구인 게시글 조회") + @GetMapping + public ResponseEntity recruitmentList(@RequestParam(required = false) List positions, + @RequestParam(required = false) List tags, + @RequestParam(required = false) String keyword, + PageRequest pageRequest) { + Pageable pageable = pageRequest.of(); + List result = recruitmentService.findRecruitmentList(positions, tags, keyword, pageable); + return ResponseEntity.ok(ResultResponse.of(ResultCode.RECRUITMENT_PAGING_GET_SUCCESS, result)); + } + + @Operation(summary = "findApplicationList", description = "프로젝트 참가 신청서 목록 조회") + @GetMapping("{recruitmentId}/applications") + public ResponseEntity applicationList(@PathVariable Long recruitmentId, + PageRequest pageRequest) { + Pageable pageable = pageRequest.of(); + List result = recruitmentService.findApplicationList(recruitmentId, pageable); + return ResponseEntity.ok(ResultResponse.of(ResultCode.APPLICATION_PAGING_GET_SUCCESS, result)); + } + + @Operation(summary = "updateRecruitment", description = "구인 게시글 수정") + @PutMapping("/{recruitmentId}") + public ResponseEntity RecruitmentModify(@PathVariable Long recruitmentId, + @RequestBody @Validated UpdateRecruitmentRequest request, + @CurrentUser Member loginUser) { + recruitmentService.modifyRecruitment(recruitmentId, request, loginUser); + return ResponseEntity.status(HttpStatus.NO_CONTENT) + .body(ResultResponse.of(ResultCode.RECRUITMENT_UPDATE_SUCCESS)); + } + + @Operation(summary = "deleteRecruitment", description = "구인 게시글 삭제(soft delete)") + @DeleteMapping("/{recruitmentId}") + public ResponseEntity recruitmentRemove(@PathVariable Long recruitmentId, + @CurrentUser Member loginUser) { + recruitmentService.removeRecruitment(recruitmentId, loginUser); + return ResponseEntity.status(HttpStatus.NO_CONTENT) + .body(ResultResponse.of(ResultCode.RECRUITMENT_DELETE_SUCCESS)); + } +} + + diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Application.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Application.java new file mode 100644 index 00000000..98abe342 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Application.java @@ -0,0 +1,55 @@ +package com.graphy.backend.domain.recruitment.domain; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.project.domain.Tag; +import com.graphy.backend.global.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Application extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String introduction; + + @Column(nullable = false) + private String github; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recruitment_id") + private Recruitment recruitment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Enumerated(EnumType.STRING) + private Position position; + + @Embedded + private ApplicationTags applicationTags; + + public void addTag(Tag tag, Integer level) { + applicationTags.add(this, tag, level); + } + + public List getTagNames() { + return this.applicationTags.getTagNames(); + } + + public List getTagIds() { + return this.applicationTags.getTagIds(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ApplicationTag.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ApplicationTag.java new file mode 100644 index 00000000..4a087c93 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ApplicationTag.java @@ -0,0 +1,36 @@ +package com.graphy.backend.domain.recruitment.domain; + +import com.graphy.backend.domain.project.domain.Tag; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApplicationTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id") + private Application application; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id") + private Tag tag; + + private Integer level; + + public ApplicationTag(Application application, Tag tag, Integer level) { + this.application = application; + this.tag = tag; + this.level = level; + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ApplicationTags.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ApplicationTags.java new file mode 100644 index 00000000..2302d5e6 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ApplicationTags.java @@ -0,0 +1,45 @@ +package com.graphy.backend.domain.recruitment.domain; + +import com.graphy.backend.domain.project.domain.Tag; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import javax.persistence.CascadeType; +import javax.persistence.Embeddable; +import javax.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Embeddable +@AllArgsConstructor +public class ApplicationTags { + @OneToMany(mappedBy = "application", cascade = CascadeType.ALL) + private List value; + + public ApplicationTags() { + this.value = new ArrayList<>(); + } + + public void clear() { + value.clear(); + } + + public List getTagNames() { + return value.stream() + .map(applicationTag -> applicationTag.getTag().getTech()) + .collect(Collectors.toList()); + } + + public void add(Application application, Tag tag, Integer level) { + value.add(new ApplicationTag(application, tag, level)); + } + + + public List getTagIds() { + return value.stream() + .map(applicationTag -> applicationTag.getTag().getId()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Position.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Position.java new file mode 100644 index 00000000..c71b79a2 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Position.java @@ -0,0 +1,5 @@ +package com.graphy.backend.domain.recruitment.domain; + +public enum Position { + BACKEND, FRONTEND, DESIGNER, PM, DEVOPS, AI +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ProcessType.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ProcessType.java new file mode 100644 index 00000000..2e724b93 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/ProcessType.java @@ -0,0 +1,5 @@ +package com.graphy.backend.domain.recruitment.domain; + +public enum ProcessType { + ONLINE, OFFLINE, MIX +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Recruitment.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Recruitment.java new file mode 100644 index 00000000..ef144f54 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/Recruitment.java @@ -0,0 +1,82 @@ +package com.graphy.backend.domain.recruitment.domain; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.project.domain.Tags; +import com.graphy.backend.domain.recruitment.dto.request.UpdateRecruitmentRequest; +import com.graphy.backend.global.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Where; + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Where(clause = "is_deleted = false") +public class Recruitment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Lob + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private Integer recruitmentCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProcessType type; + + @Column(nullable = false) + private LocalDateTime endDate; + + @Column(nullable = false) + private LocalDateTime period; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Position position; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Embedded + private RecruitmentTags recruitmentTags; + + public void addTag(Tags tags) { + recruitmentTags.add(this, tags); + } + + public List getTagNames() { + return this.recruitmentTags.getTagNames(); + } + + public List getTagIds() { + return this.recruitmentTags.getTagIds(); + } + + public void updateRecruitment(UpdateRecruitmentRequest request, Tags tags) { + this.title = request.getTitle(); + this.content = request.getContent(); + this.recruitmentCount = request.getRecruitmentCount(); + this.type = request.getType(); + this.endDate = request.getEndDate(); + this.position = request.getPosition(); + recruitmentTags.clear(); + addTag(tags); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/RecruitmentTag.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/RecruitmentTag.java new file mode 100644 index 00000000..a5f4d7f5 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/RecruitmentTag.java @@ -0,0 +1,33 @@ +package com.graphy.backend.domain.recruitment.domain; + +import com.graphy.backend.domain.project.domain.Tag; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecruitmentTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "recruitment_id") + private Recruitment recruitment; + + @ManyToOne + @JoinColumn(name = "tag_id") + private Tag tag; + + public RecruitmentTag(Recruitment recruitment, Tag tag) { + this.recruitment = recruitment; + this.tag = tag; + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/RecruitmentTags.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/RecruitmentTags.java new file mode 100644 index 00000000..d8d2936c --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/domain/RecruitmentTags.java @@ -0,0 +1,45 @@ +package com.graphy.backend.domain.recruitment.domain; + +import com.graphy.backend.domain.project.domain.Tags; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import javax.persistence.CascadeType; +import javax.persistence.Embeddable; +import javax.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Embeddable +@AllArgsConstructor +public class RecruitmentTags { + @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL) + private List value; + + public RecruitmentTags() { + this.value = new ArrayList<>(); + } + + public void clear() { + value.clear(); + } + + public List getTagNames() { + return value.stream() + .map(recruitmentTag -> recruitmentTag.getTag().getTech()) + .collect(Collectors.toList()); + } + + public void add(Recruitment recruitment, Tags tags) { + tags.getTags() + .forEach(tag -> value.add(new RecruitmentTag(recruitment, tag))); + } + + public List getTagIds() { + return value.stream() + .map(recruitmentTag -> recruitmentTag.getTag().getId()) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/CreateApplicationRequest.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/CreateApplicationRequest.java new file mode 100644 index 00000000..e0b4f10b --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/CreateApplicationRequest.java @@ -0,0 +1,44 @@ +package com.graphy.backend.domain.recruitment.dto.request; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.recruitment.domain.Application; +import com.graphy.backend.domain.recruitment.domain.ApplicationTags; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateApplicationRequest { + @NotBlank(message = "introduction cannot be blank") + private String introduction; + + @NotBlank(message = "github cannot be blank") + private String github; + + @NotNull(message = "recruitmentId cannot be null") + private Long recruitmentId; + + @Valid + private List techLevels; + + public Application toEntity(Recruitment recruitment, Member member) { + return Application.builder() + .introduction(introduction) + .github(github) + .recruitment(recruitment) + .member(member) + .position(recruitment.getPosition()) + .applicationTags(new ApplicationTags()) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/CreateRecruitmentRequest.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/CreateRecruitmentRequest.java new file mode 100644 index 00000000..b11aefc7 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/CreateRecruitmentRequest.java @@ -0,0 +1,62 @@ +package com.graphy.backend.domain.recruitment.dto.request; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.ProcessType; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import com.graphy.backend.domain.recruitment.domain.RecruitmentTags; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateRecruitmentRequest { + + @NotBlank(message = "Recruitment title cannot be blank") + private String title; + + @NotBlank(message = "content cannot be blank") + private String content; + + @NotNull(message = "ProcessType cannot be null") + private ProcessType type; + + @NotNull(message = "endDate cannot be null") + private LocalDateTime endDate; + + @NotNull(message = "period cannot be null") + private LocalDateTime period; + + @NotNull(message = "position cannot be null") + private Position position; + + @Positive(message = "최소 인원은 1명입니다.") + @NotNull(message = "recruitmentCount cannot be null") + private Integer recruitmentCount; + + private List techTags; + + public Recruitment toEntity(Member member) { + return Recruitment.builder() + .member(member) + .title(title) + .content(content) + .type(type) + .endDate(endDate) + .period(period) + .position(position) + .recruitmentCount(recruitmentCount) + .recruitmentTags(new RecruitmentTags()) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/TechLevelDto.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/TechLevelDto.java new file mode 100644 index 00000000..76cdb5e0 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/TechLevelDto.java @@ -0,0 +1,25 @@ +package com.graphy.backend.domain.recruitment.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TechLevelDto { + @NotBlank(message = "tech cannot be blank") + private String tech; + + @Min(value = 1, message = "최소 숙련도는 1입니다.") + @Max(value = 5, message = "최대 숙련도는 5입니다.") + @NotNull(message = "level cannot be null") + private Integer level; +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/UpdateRecruitmentRequest.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/UpdateRecruitmentRequest.java new file mode 100644 index 00000000..c5c507bd --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/request/UpdateRecruitmentRequest.java @@ -0,0 +1,44 @@ +package com.graphy.backend.domain.recruitment.dto.request; + +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.ProcessType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateRecruitmentRequest { + @NotBlank(message = "Recruitment title cannot be blank") + private String title; + + @NotBlank(message = "content cannot be blank") + private String content; + + @NotNull(message = "ProcessType cannot be null") + private ProcessType type; + + @NotNull(message = "endDate cannot be null") + private LocalDateTime endDate; + + @NotNull(message = "period cannot be null") + private LocalDateTime period; + + @NotNull(message = "position cannot be null") + private Position position; + + @Positive(message = "최소 인원은 1명입니다.") + @NotNull(message = "recruitmentCount cannot be null") + private Integer recruitmentCount; + + private List techTags; +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetApplicationDetailResponse.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetApplicationDetailResponse.java new file mode 100644 index 00000000..9febd19a --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetApplicationDetailResponse.java @@ -0,0 +1,32 @@ +package com.graphy.backend.domain.recruitment.dto.response; + +import com.graphy.backend.domain.recruitment.domain.Application; +import com.graphy.backend.domain.recruitment.dto.request.TechLevelDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetApplicationDetailResponse { + private Long id; + private String memberNickname; + private String introduction; + private String github; + private List techLevels; + + public static GetApplicationDetailResponse of(Application application, List techLevels) { + return GetApplicationDetailResponse.builder() + .id(application.getId()) + .memberNickname(application.getMember().getNickname()) + .introduction(application.getIntroduction()) + .github(application.getGithub()) + .techLevels(techLevels) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetApplicationResponse.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetApplicationResponse.java new file mode 100644 index 00000000..56310adb --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetApplicationResponse.java @@ -0,0 +1,24 @@ +package com.graphy.backend.domain.recruitment.dto.response; + +import com.graphy.backend.domain.recruitment.domain.Application; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetApplicationResponse { + private Long id; + private String memberNickname; + private String introduction; + + public static GetApplicationResponse from(Application application) { + return GetApplicationResponse.builder() + .id(application.getId()) + .memberNickname(application.getMember().getNickname()) + .introduction(application.getIntroduction()) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetRecruitmentDetailResponse.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetRecruitmentDetailResponse.java new file mode 100644 index 00000000..793956d1 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetRecruitmentDetailResponse.java @@ -0,0 +1,50 @@ +package com.graphy.backend.domain.recruitment.dto.response; + +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.ProcessType; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetRecruitmentDetailResponse { + private Long id; + + private String title; + + private String content; + + private ProcessType type; + + private LocalDateTime endDate; + + private LocalDateTime period; + + private Position position; + + private Integer recruitmentCount; + + private List techTags; + + public static GetRecruitmentDetailResponse from(Recruitment recruitment) { + return GetRecruitmentDetailResponse.builder() + .id(recruitment.getId()) + .title(recruitment.getTitle()) + .content(recruitment.getContent()) + .type(recruitment.getType()) + .endDate(recruitment.getEndDate()) + .period(recruitment.getPeriod()) + .position(recruitment.getPosition()) + .recruitmentCount(recruitment.getRecruitmentCount()) + .techTags(recruitment.getTagNames()) + .build(); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetRecruitmentResponse.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetRecruitmentResponse.java new file mode 100644 index 00000000..198c1cc1 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/dto/response/GetRecruitmentResponse.java @@ -0,0 +1,44 @@ +package com.graphy.backend.domain.recruitment.dto.response; + +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetRecruitmentResponse { + + private Long id; + + private String nickname; + + private String title; + + private Position position; + + private List techTags; + + public static GetRecruitmentResponse from(Recruitment recruitment) { + return GetRecruitmentResponse.builder() + .id(recruitment.getId()) + .nickname(recruitment.getMember().getNickname()) + .title(recruitment.getTitle()) + .position(recruitment.getPosition()) + .techTags(recruitment.getTagNames()) + .build(); + } + + public static List listOf(List recruitments) { + return recruitments.stream() + .map(GetRecruitmentResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/ApplicationCustomRepository.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/ApplicationCustomRepository.java new file mode 100644 index 00000000..f3008501 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/ApplicationCustomRepository.java @@ -0,0 +1,9 @@ +package com.graphy.backend.domain.recruitment.repository; + +import com.graphy.backend.domain.recruitment.domain.Application; + +import java.util.Optional; + +public interface ApplicationCustomRepository { + Optional findApplicationWithFetch(Long applicationId); +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/ApplicationRepository.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/ApplicationRepository.java new file mode 100644 index 00000000..0c0a4ac8 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/ApplicationRepository.java @@ -0,0 +1,12 @@ +package com.graphy.backend.domain.recruitment.repository; + +import com.graphy.backend.domain.recruitment.domain.Application; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationRepository extends JpaRepository, ApplicationCustomRepository { + Page findAllByRecruitmentId(Long id, Pageable pageable); + + Boolean existsByMemberIdAndRecruitmentId(Long memberId, Long recruitmentId); +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentCustomRepository.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentCustomRepository.java new file mode 100644 index 00000000..9456df96 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentCustomRepository.java @@ -0,0 +1,17 @@ +package com.graphy.backend.domain.recruitment.repository; + +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +public interface RecruitmentCustomRepository { + List findRecruitments(List positions, + List tags, + String keyword, + Pageable pageable); + + Optional findRecruitmentWithMember(Long recruitmentId); +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentRepository.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentRepository.java new file mode 100644 index 00000000..e88fcf74 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentRepository.java @@ -0,0 +1,7 @@ +package com.graphy.backend.domain.recruitment.repository; + +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecruitmentRepository extends JpaRepository, RecruitmentCustomRepository { +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentTagRepository.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentTagRepository.java new file mode 100644 index 00000000..ef2cb7c4 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/RecruitmentTagRepository.java @@ -0,0 +1,10 @@ +package com.graphy.backend.domain.recruitment.repository; + +import com.graphy.backend.domain.recruitment.domain.RecruitmentTag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface RecruitmentTagRepository extends JpaRepository { + @Transactional + public void deleteAllByRecruitmentId(Long id); +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/custom/ApplicationCustomRepositoryImpl.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/custom/ApplicationCustomRepositoryImpl.java new file mode 100644 index 00000000..e3bc3400 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/custom/ApplicationCustomRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.graphy.backend.domain.recruitment.repository.custom; + +import com.graphy.backend.domain.recruitment.domain.Application; +import com.graphy.backend.domain.recruitment.repository.ApplicationCustomRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import static com.graphy.backend.domain.member.domain.QMember.member; +import static com.graphy.backend.domain.project.domain.QTag.tag; +import static com.graphy.backend.domain.recruitment.domain.QApplication.application; +import static com.graphy.backend.domain.recruitment.domain.QApplicationTag.applicationTag; + +@RequiredArgsConstructor +public class ApplicationCustomRepositoryImpl implements ApplicationCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Optional findApplicationWithFetch(Long applicationId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(application) + .where(application.id.eq(applicationId)) + .join(application.member, member).fetchJoin() + .leftJoin(application.applicationTags.value, applicationTag).fetchJoin() + .leftJoin(applicationTag.tag, tag).fetchJoin() + .fetchOne()); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/custom/RecruitmentCustomRepositoryImpl.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/custom/RecruitmentCustomRepositoryImpl.java new file mode 100644 index 00000000..215e60bc --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/repository/custom/RecruitmentCustomRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.graphy.backend.domain.recruitment.repository.custom; + +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import com.graphy.backend.domain.recruitment.repository.RecruitmentCustomRepository; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static com.graphy.backend.domain.member.domain.QMember.member; +import static com.graphy.backend.domain.project.domain.QTag.tag; +import static com.graphy.backend.domain.recruitment.domain.QRecruitment.recruitment; +import static com.graphy.backend.domain.recruitment.domain.QRecruitmentTag.recruitmentTag; + +@RequiredArgsConstructor +public class RecruitmentCustomRepositoryImpl implements RecruitmentCustomRepository { + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findRecruitments(List positions, + List tags, + String title, + Pageable pageable) { + return jpaQueryFactory + .selectFrom(recruitment) + .where( + tagIn(tags), + positionIn(positions), + recruitmentTitleLike(title) + ) + .join(recruitment.member, member).fetchJoin() + .leftJoin(recruitmentTag).on(recruitmentTag.recruitment.eq(recruitment)) + .leftJoin(recruitmentTag.tag, tag) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + @Override + public Optional findRecruitmentWithMember(Long recruitmentId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(recruitment) + .where(recruitment.id.eq(recruitmentId)) + .join(recruitment.member, member).fetchJoin() + .fetchOne()); + } + + private BooleanExpression tagIn(List tags) { + if (tags == null || tags.isEmpty()) return null; + return tag.tech.in(tags); + } + + private BooleanExpression positionIn(List positions) { + if (positions == null || positions.isEmpty()) return null; + return recruitment.position.in(positions); + } + + private BooleanExpression recruitmentTitleLike(String title) { + return title != null ? recruitment.title.like(title) : null; + } +} + diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/service/ApplicationService.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/service/ApplicationService.java new file mode 100644 index 00000000..21bdb599 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/service/ApplicationService.java @@ -0,0 +1,76 @@ +package com.graphy.backend.domain.recruitment.service; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.notification.domain.NotificationType; +import com.graphy.backend.domain.notification.dto.NotificationDto; +import com.graphy.backend.domain.notification.service.NotificationService; +import com.graphy.backend.domain.project.domain.Tag; +import com.graphy.backend.domain.project.service.TagService; +import com.graphy.backend.domain.recruitment.domain.Application; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import com.graphy.backend.domain.recruitment.dto.request.CreateApplicationRequest; +import com.graphy.backend.domain.recruitment.dto.request.TechLevelDto; +import com.graphy.backend.domain.recruitment.dto.response.GetApplicationDetailResponse; +import com.graphy.backend.domain.recruitment.repository.ApplicationRepository; +import com.graphy.backend.global.error.ErrorCode; +import com.graphy.backend.global.error.exception.EmptyResultException; +import com.graphy.backend.global.error.exception.InvalidMemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ApplicationService { + private final ApplicationRepository applicationRepository; + private final RecruitmentService recruitmentService; + private final NotificationService notificationService; + private final TagService tagService; + + @Transactional + public void addApplication(CreateApplicationRequest request, Member loginUser) { + Recruitment recruitment = recruitmentService.getRecruitmentById(request.getRecruitmentId()); + + if (applicationRepository.existsByMemberIdAndRecruitmentId(loginUser.getId(), recruitment.getId())) + throw new InvalidMemberException(ErrorCode.APPLICATION_ALREADY_EXIST); + + Application application = request.toEntity(recruitment, loginUser); + + if (request.getTechLevels() != null) { + request.getTechLevels().forEach(techLevel -> { + Tag tag = tagService.findTagByTech(techLevel.getTech()); + application.addTag(tag, techLevel.getLevel()); + }); + } + applicationRepository.save(application); + + NotificationType notificationType = NotificationType.RECRUITMENT; + notificationType.setMessage(loginUser.getNickname(), "요청했습니다."); + NotificationDto notificationDto = NotificationDto.builder() + .type(notificationType) + .content(notificationType.getMessage()) + .build(); + + notificationService.addNotification(notificationDto, recruitment.getMember().getId()); + } + + + public GetApplicationDetailResponse findApplicationById(Long applicationId) { + Application application = applicationRepository.findApplicationWithFetch(applicationId) + .orElseThrow( + () -> new EmptyResultException(ErrorCode.APPLICATION_NOT_EXIST) + ); + List techLevels = application.getApplicationTags().getValue().stream() + .map(applicationTag -> TechLevelDto.builder() + .tech(applicationTag.getTag().getTech()) + .level(applicationTag.getLevel()) + .build()) + .collect(Collectors.toList()); + + return GetApplicationDetailResponse.of(application, techLevels); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/service/RecruitmentService.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/service/RecruitmentService.java new file mode 100644 index 00000000..32bf614e --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/service/RecruitmentService.java @@ -0,0 +1,100 @@ +package com.graphy.backend.domain.recruitment.service; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.project.domain.Tags; +import com.graphy.backend.domain.project.service.TagService; +import com.graphy.backend.domain.recruitment.domain.Application; +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import com.graphy.backend.domain.recruitment.dto.request.CreateRecruitmentRequest; +import com.graphy.backend.domain.recruitment.dto.request.UpdateRecruitmentRequest; +import com.graphy.backend.domain.recruitment.dto.response.GetApplicationResponse; +import com.graphy.backend.domain.recruitment.dto.response.GetRecruitmentDetailResponse; +import com.graphy.backend.domain.recruitment.dto.response.GetRecruitmentResponse; +import com.graphy.backend.domain.recruitment.repository.ApplicationRepository; +import com.graphy.backend.domain.recruitment.repository.RecruitmentRepository; +import com.graphy.backend.global.error.ErrorCode; +import com.graphy.backend.global.error.exception.EmptyResultException; +import com.graphy.backend.global.error.exception.InvalidMemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RecruitmentService { + private final RecruitmentRepository recruitmentRepository; + private final RecruitmentTagService recruitmentTagService; + private final ApplicationRepository applicationRepository; + private final TagService tagService; + + @Transactional + public void addRecruitment(CreateRecruitmentRequest request, Member loginUser) { + Recruitment recruitment = request.toEntity(loginUser); + if (request.getTechTags() != null) { + Tags foundTags = tagService.findTagListByName(request.getTechTags()); + recruitment.addTag(foundTags); + } + recruitmentRepository.save(recruitment); + } + + public GetRecruitmentDetailResponse findRecruitmentById(Long recruitmentId) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow( + () -> new EmptyResultException(ErrorCode.RECRUITMENT_NOT_EXIST) + ); + return GetRecruitmentDetailResponse.from(recruitment); + } + + public List findRecruitmentList(List positions, + List tags, + String keyword, + Pageable pageable) { + List result = recruitmentRepository.findRecruitments(positions, tags, keyword, pageable); + if (result.isEmpty()) throw new EmptyResultException(ErrorCode.RECRUITMENT_NOT_EXIST); + + return GetRecruitmentResponse.listOf(result); + } + + @Transactional + public void modifyRecruitment(Long recruitmentId, UpdateRecruitmentRequest request, Member loginUser) { + Recruitment recruitment = recruitmentRepository.findRecruitmentWithMember(recruitmentId).orElseThrow( + () -> new EmptyResultException(ErrorCode.RECRUITMENT_NOT_EXIST) + ); + + if (recruitment.getMember().getId() != loginUser.getId()) + throw new InvalidMemberException(ErrorCode.INVALID_MEMBER); + + recruitmentTagService.removeProjectTag(recruitment.getId()); + Tags tags = tagService.findTagListByName(request.getTechTags()); + recruitment.updateRecruitment(request, tags); + } + + @Transactional + public void removeRecruitment(Long recruitmentId, Member loginUser) { + Recruitment recruitment = recruitmentRepository.findRecruitmentWithMember(recruitmentId).orElseThrow( + () -> new EmptyResultException(ErrorCode.RECRUITMENT_NOT_EXIST) + ); + + if (recruitment.getMember().getId() != loginUser.getId()) + throw new InvalidMemberException(ErrorCode.INVALID_MEMBER); + + recruitment.delete(); + } + + public Recruitment getRecruitmentById(Long id) { + return recruitmentRepository.findById(id).orElseThrow(() -> new EmptyResultException(ErrorCode.RECRUITMENT_NOT_EXIST)); + } + + public List findApplicationList(Long recruitmentId, Pageable pageable) { + Page applicationList = applicationRepository.findAllByRecruitmentId(recruitmentId, pageable); + return applicationList.stream() + .map(GetApplicationResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/graphy/backend/domain/recruitment/service/RecruitmentTagService.java b/backend/src/main/java/com/graphy/backend/domain/recruitment/service/RecruitmentTagService.java new file mode 100644 index 00000000..a6779391 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/recruitment/service/RecruitmentTagService.java @@ -0,0 +1,18 @@ +package com.graphy.backend.domain.recruitment.service; + +import com.graphy.backend.domain.recruitment.repository.RecruitmentTagRepository; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class RecruitmentTagService { + private final RecruitmentTagRepository recruitmentTagRepository; + + @Transactional + public void removeProjectTag(Long projectId) { + recruitmentTagRepository.deleteAllByRecruitmentId(projectId); + } +} diff --git a/backend/src/main/java/com/graphy/backend/global/common/BaseEntity.java b/backend/src/main/java/com/graphy/backend/global/common/BaseEntity.java index 660bd157..570ecb14 100644 --- a/backend/src/main/java/com/graphy/backend/global/common/BaseEntity.java +++ b/backend/src/main/java/com/graphy/backend/global/common/BaseEntity.java @@ -24,8 +24,6 @@ public abstract class BaseEntity { @Column(name = "createdAt") private LocalDateTime createdAt; - - @LastModifiedDate @Column(name = "updatedAt") private LocalDateTime updatedAt; diff --git a/backend/src/main/java/com/graphy/backend/global/common/PageRequest.java b/backend/src/main/java/com/graphy/backend/global/common/PageRequest.java index 914555ca..e46a8995 100644 --- a/backend/src/main/java/com/graphy/backend/global/common/PageRequest.java +++ b/backend/src/main/java/com/graphy/backend/global/common/PageRequest.java @@ -1,21 +1,24 @@ package com.graphy.backend.global.common; +import com.graphy.backend.global.error.exception.BusinessException; import org.springframework.data.domain.Sort; +import static com.graphy.backend.global.error.ErrorCode.INPUT_INVALID_VALUE; + public class PageRequest { + private static final int DEFAULT_SIZE = 10; + private static final int MAX_SIZE = 50; private int page = 1; private int size = 10; private Sort.Direction direction = Sort.Direction.DESC; - + public void setPage(int page) { this.page = page <= 0 ? 1 : page; } public void setSize(int size) { - int DEFAULT_SIZE = 10; - int MAX_SIZE = 50; this.size = size > MAX_SIZE ? DEFAULT_SIZE : size; } @@ -24,6 +27,16 @@ public void setDirection(Sort.Direction direction) { } public org.springframework.data.domain.PageRequest of() { + if (size <= 0) { + throw new BusinessException(INPUT_INVALID_VALUE); + } return org.springframework.data.domain.PageRequest.of(page - 1, size, direction, "createdAt"); } + + public org.springframework.data.domain.PageRequest jobOf() { + if (size <= 0) { + throw new BusinessException(INPUT_INVALID_VALUE); + } + return org.springframework.data.domain.PageRequest.of(page - 1, size, direction, "expirationDate"); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/global/config/AsyncConfig.java b/backend/src/main/java/com/graphy/backend/global/config/AsyncConfig.java index abd49c86..5b563cc5 100644 --- a/backend/src/main/java/com/graphy/backend/global/config/AsyncConfig.java +++ b/backend/src/main/java/com/graphy/backend/global/config/AsyncConfig.java @@ -14,8 +14,8 @@ public ThreadPoolTaskExecutor threadPoolTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(30); - executor.setQueueCapacity(50); - executor.setThreadNamePrefix("LSH-ASYNC-"); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("ASYNC-"); executor.initialize(); return executor; } diff --git a/backend/src/main/java/com/graphy/backend/global/config/JavaMailConfig.java b/backend/src/main/java/com/graphy/backend/global/config/JavaMailConfig.java new file mode 100644 index 00000000..8ccc71e1 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/global/config/JavaMailConfig.java @@ -0,0 +1,40 @@ +package com.graphy.backend.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class JavaMailConfig { + + @Value("${spring.mail.username}") + private String username; + @Value("${spring.mail.password}") + private String password; + @Value("${spring.mail.host}") + private String host; + + @Bean + public JavaMailSender javaMailService() { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + + javaMailSender.setProtocol("smtp"); + javaMailSender.setHost(host); + javaMailSender.setUsername(username); + javaMailSender.setPassword(password); + javaMailSender.setJavaMailProperties(getMailProperties()); + + return javaMailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + properties.setProperty("mail.smtp.auth", "true"); + properties.setProperty("mail.smtp.starttls.enable", "true"); + return properties; + } +} diff --git a/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java b/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java index 99bfe313..fba011cf 100644 --- a/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java @@ -38,7 +38,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws .antMatchers("/api/v1/auth/signup", "/api/v1/auth/signin", "/api/v1/auth/logout", - "/api/v1/projects/search", + "/api/v1/projects", + "/api/v1/members/**", "/swagger-ui/**").permitAll() .antMatchers(HttpMethod.GET, "/api/v1/projects/{projectId}").permitAll() .antMatchers(HttpMethod.GET, "/api/v1/comments/{commentId}").permitAll() diff --git a/backend/src/main/java/com/graphy/backend/global/error/ErrorCode.java b/backend/src/main/java/com/graphy/backend/global/error/ErrorCode.java index fe077517..5b0f1ab6 100644 --- a/backend/src/main/java/com/graphy/backend/global/error/ErrorCode.java +++ b/backend/src/main/java/com/graphy/backend/global/error/ErrorCode.java @@ -19,20 +19,37 @@ public enum ErrorCode { // Member MEMBER_NOT_EXIST(HttpStatus.NOT_FOUND, "M001", "존재하지 않는 사용자"), MEMBER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "M002", "이미 존재하는 이메일"), + INVALID_MEMBER(HttpStatus.FORBIDDEN, "M003", "권한이 없는 사용자"), // Follow FOLLOW_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "F001", "이미 존재하는 팔로우"), - FOLLOW_NOT_EXIST(HttpStatus.NOT_FOUND, "M002", "존재하지 않는 팔로우"), + FOLLOW_NOT_EXIST(HttpStatus.NOT_FOUND, "F002", "존재하지 않는 팔로우"), + FOLLOW_SELF(HttpStatus.CONFLICT, "F003", "자기 자신을 팔로우 할 수 없음"), + // Project PROJECT_DELETED_OR_NOT_EXIST(HttpStatus.NOT_FOUND, "P001", "이미 삭제되거나 존재하지 않는 프로젝트"), // Comment COMMENT_DELETED_OR_NOT_EXIST(HttpStatus.NOT_FOUND, "C001", "이미 삭제되거나 존재하지 않는 댓글"), - // ChatGPT + // Recruitment + RECRUITMENT_NOT_EXIST(HttpStatus.NOT_FOUND, "R001", "존재하지 않는 구인 게시글"), + + // Application + APPLICATION_NOT_EXIST(HttpStatus.NOT_FOUND, "AP001", "존재하지 않는 프로젝트 참가 신청서"), + APPLICATION_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "AP002", "이미 존재하는 프로젝트 참가 신청서"), + + // ChatGPT, REQUEST_TOO_MUCH_TOKENS(HttpStatus.BAD_REQUEST, "AI001", "GPT에 보내야 할 요청 길이 제한 초과"), - ; + // Message + MESSAGE_NOT_EXIST(HttpStatus.NOT_FOUND, "MSG001", "존재하지 않는 메세지"), + + // Notification + SEND_EMAIL_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "EM001", "이메일 전송 실패"), + + // Job + JOB_DELETED_OR_NOT_EXIST(HttpStatus.NOT_FOUND, "J001", "이미 삭제되거나 존재하지 않는 채용공고"); private final HttpStatus status; private final String errorCode; private final String message; diff --git a/backend/src/main/java/com/graphy/backend/global/error/GlobalExceptionHandler.java b/backend/src/main/java/com/graphy/backend/global/error/GlobalExceptionHandler.java index 29da1241..6dfc1f16 100644 --- a/backend/src/main/java/com/graphy/backend/global/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/graphy/backend/global/error/GlobalExceptionHandler.java @@ -27,7 +27,9 @@ protected ResponseEntity handleException(Exception e) { return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } - @ExceptionHandler + @ExceptionHandler({ + BusinessException.class, + }) protected ResponseEntity handleRuntimeException(BusinessException e) { final ErrorCode errorCode = e.getErrorCode(); final ErrorResponse response = makeErrorResponse(errorCode); @@ -59,31 +61,25 @@ protected ResponseEntity handleDataIntegrityViolationException(Al return new ResponseEntity<>(response, errorCode.getStatus()); } + @Override protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatus status, WebRequest request) { log.warn(e.getMessage()); - return handleExceptionInternal(e, ErrorCode.INPUT_INVALID_VALUE, e.getBindingResult(),request); - } - - - private ResponseEntity handleExceptionInternal( - Exception e, ErrorCode errorCode, WebRequest request) { - log.error(e.getMessage(), e); - return super.handleExceptionInternal(e, ErrorResponse.of(errorCode), HttpHeaders.EMPTY, errorCode.getStatus(), request); + return handleExceptionInternal(e, e.getBindingResult(),request); } private ResponseEntity handleExceptionInternal( - Exception e, ErrorCode errorCode, BindingResult bindingResult, WebRequest request) { + Exception e, BindingResult bindingResult, WebRequest request) { log.error(e.getMessage(), e); - ErrorResponse errorResponse = makeErrorResponse(errorCode,bindingResult); - return super.handleExceptionInternal(e, errorResponse, HttpHeaders.EMPTY, errorCode.getStatus(), request); + ErrorResponse errorResponse = makeErrorResponse(bindingResult); + return super.handleExceptionInternal(e, errorResponse, HttpHeaders.EMPTY, ErrorCode.INPUT_INVALID_VALUE.getStatus(), request); } - private ErrorResponse makeErrorResponse(ErrorCode errorCode, BindingResult bindingResult) { + private ErrorResponse makeErrorResponse(BindingResult bindingResult) { return ErrorResponse.builder() - .message(errorCode.getMessage()) - .code(errorCode.getErrorCode()) + .message(ErrorCode.INPUT_INVALID_VALUE.getMessage()) + .code(ErrorCode.INPUT_INVALID_VALUE.getErrorCode()) .errors(ErrorResponse.FieldError.of(bindingResult)) .build(); } diff --git a/backend/src/main/java/com/graphy/backend/global/error/exception/InvalidMemberException.java b/backend/src/main/java/com/graphy/backend/global/error/exception/InvalidMemberException.java new file mode 100644 index 00000000..21c5b231 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/global/error/exception/InvalidMemberException.java @@ -0,0 +1,14 @@ +package com.graphy.backend.global.error.exception; + +import com.graphy.backend.global.error.ErrorCode; +import lombok.Getter; + +@Getter +public class InvalidMemberException extends BusinessException { + private final ErrorCode errorCode; + + public InvalidMemberException(ErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/com/graphy/backend/global/result/ResultCode.java b/backend/src/main/java/com/graphy/backend/global/result/ResultCode.java index da056ea0..74f4409c 100644 --- a/backend/src/main/java/com/graphy/backend/global/result/ResultCode.java +++ b/backend/src/main/java/com/graphy/backend/global/result/ResultCode.java @@ -23,28 +23,48 @@ public enum ResultCode { FOLLOWING_CREATE_SUCCESS("F002", "팔로잉 성공"), FOLLOW_DELETE_SUCCESS("F003", "언팔로잉 성공"), - // Like LIKE_PROJECT_SUCCESS("L001", "프로젝트 좋아요/좋아요 취소 성공"), LIKED_MEMBER_GET_SUCCESS("L002", "좋아요 누른 유저 조회 성공"), - // project + // Project PROJECT_CREATE_SUCCESS("P001", "프로젝트 생성 성공"), PROJECT_GET_SUCCESS("P002", "프로젝트 조회 성공"), PROJECT_DELETE_SUCCESS("P003", "프로젝트 삭제 성공"), PROJECT_UPDATE_SUCCESS("P004", "프로젝트 수정 성공"), PROJECT_PAGING_GET_SUCCESS("P005", "프로젝트 페이징 조회 성공"), - // comment + // Comment RECOMMENT_GET_SUCCESS("C001", "답글 조회 성공"), COMMENT_CREATE_SUCCESS("C002", "댓글 생성 성공"), COMMENT_DELETE_SUCCESS("C003", "댓글 삭제 성공"), COMMENT_UPDATE_SUCCESS("C004", "댓글 수정 성공"), - // plan - PLAN_CREATE_SUCCESS("PL001", "고도화 계획 생성 성공"); + // Recruitment + RECRUITMENT_CREATE_SUCCESS("R001", "구인 게시글 생성 성공"), + RECRUITMENT_GET_SUCCESS("R002", "구인 게시글 조회 성공"), + RECRUITMENT_DELETE_SUCCESS("R003", "구인 게시글 삭제 성공"), + RECRUITMENT_UPDATE_SUCCESS("R004", "구인 게시글 수정 성공"), + RECRUITMENT_PAGING_GET_SUCCESS("R005", "구인 게시글 페이징 조회 성공"), + + // Plan, + PLAN_CREATE_SUCCESS("PL001", "고도화 계획 생성 성공"), + + // Application + APPLICATION_CREATE_SUCCESS("AP001", "프로젝트 참가 신청 성공"), + APPLICATION_GET_SUCCESS("AP002", "프로젝트 참가 신청서 단건 조회 성공"), + APPLICATION_PAGING_GET_SUCCESS("AP003", "프로젝트 참가 신청서 페이징 조회 성공"), + + // Message + MESSAGE_CREATE_SUCCESS("MSG001", "쪽지 전송 성공"), + MESSAGE_GET_SUCCESS("MSG002", "쪽지 단건 조회 성공"), + MESSAGE_PAGING_GET_SUCCESS("MSG003", "쪽지 페이징 조회 성공"), + // Notification + NOTIFICATION_PAGING_GET_SUCCESS("N001", "알림 페이징 조회 성공"), + // Job + JOB_PAGING_GET_SUCCESS("J001", "채용공고 페이징 조회 성공"); private final String code; private final String message; diff --git a/backend/src/main/resources/static/images/image-1.png b/backend/src/main/resources/static/images/image-1.png new file mode 100644 index 00000000..d4a4a4ce Binary files /dev/null and b/backend/src/main/resources/static/images/image-1.png differ diff --git a/backend/src/main/resources/static/images/image-2.png b/backend/src/main/resources/static/images/image-2.png new file mode 100644 index 00000000..e54743f9 Binary files /dev/null and b/backend/src/main/resources/static/images/image-2.png differ diff --git a/backend/src/main/resources/tag.txt b/backend/src/main/resources/tag.txt index e4bffbc6..2929d928 100644 --- a/backend/src/main/resources/tag.txt +++ b/backend/src/main/resources/tag.txt @@ -7,6 +7,7 @@ Nextjs Nodejs Java Spring +Spring Boot Go Nestjs Kotlin diff --git a/backend/src/main/resources/templates/email/index.html b/backend/src/main/resources/templates/email/index.html new file mode 100644 index 00000000..d24acd64 --- /dev/null +++ b/backend/src/main/resources/templates/email/index.html @@ -0,0 +1,392 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+ + + +
+
+
+ + + + + + + +
+ + + + + + + +
+   +
+ +
+ +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + + + + + +
+ + + + + +
+ Image + +
+ +
+ +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + + + + + +
+ + + + + +
+ + Image + +
+ +
+ + + + + + + +
+ +
+

확인하지 않은 알림이 있어요!

+
+ +
+ +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + + + + + +
+ +
+

내용

+

 

+

내용을 확인하고 싶다면 아래 버튼을 눌러주세요.

+
+ +
+ + + + + + + +
+ + + + +
+ + + + + + + +
+ +
+

Graphy 서비스에서 제공하는 알림 메일입니다. 

+
+ +
+ +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + + + + + +
+ + + + + + + +
+   +
+ +
+ +
+
+
+ + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ +
+
+
+ + +
+
+
+ + + + +
+ + + + + diff --git a/backend/src/test/java/com/graphy/backend/domain/comment/repository/CommentRepositoryTest.java b/backend/src/test/java/com/graphy/backend/domain/comment/repository/CommentRepositoryTest.java index d1aa61c9..c46acd7d 100644 --- a/backend/src/test/java/com/graphy/backend/domain/comment/repository/CommentRepositoryTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/comment/repository/CommentRepositoryTest.java @@ -31,8 +31,8 @@ JpaAuditingConfig.class, QueryDslConfig.class }) -@ActiveProfiles(TestProfile.TEST) -public class CommentRepositoryTest { +@ActiveProfiles(TestProfile.UNIT) +class CommentRepositoryTest { @Autowired CommentRepository commentRepository; @@ -107,7 +107,7 @@ public void setup() { @Test @DisplayName("프로젝트 ID를 입력 받으면 마스킹을 적용한 댓글 목록을 조회한다") - public void findCommentWithMakingTest() throws Exception { + void findCommentWithMakingTest() throws Exception { // given Comment parentComment2 = commentRepository.save(Comment.builder() .content("comment1") @@ -135,7 +135,7 @@ public void findCommentWithMakingTest() throws Exception { @Test @DisplayName("댓글 목록 조회 시 댓글이 없으면 빈 리스트가 반환된다") - public void findCommentWithMakingEmptyListTest() throws Exception { + void findCommentWithMakingEmptyListTest() throws Exception { // given clearRepository(); @@ -148,7 +148,7 @@ public void findCommentWithMakingEmptyListTest() throws Exception { @Test @DisplayName("댓글 ID를 입력 받아 답글 목록을 조회한다") - public void findReCommentListTest() throws Exception { + void findReCommentListTest() throws Exception { // when List actual = commentRepository.findReplyList(parentComment.getId()); @@ -170,7 +170,7 @@ public void findReCommentListTest() throws Exception { @Test @DisplayName("답글 목록 조회 시 답글이 없으면 빈 리스트가 반환된다") - public void findReCommentListEmptyListTest() throws Exception { + void findReCommentListEmptyListTest() throws Exception { // given Long 답글이_존재하지_않는_댓글_ID = 0L; diff --git a/backend/src/test/java/com/graphy/backend/domain/follow/repository/FollowRepositoryTest.java b/backend/src/test/java/com/graphy/backend/domain/follow/repository/FollowRepositoryTest.java deleted file mode 100644 index ce671efa..00000000 --- a/backend/src/test/java/com/graphy/backend/domain/follow/repository/FollowRepositoryTest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.graphy.backend.domain.follow.repository; - -public class FollowRepositoryTest { -} diff --git a/backend/src/test/java/com/graphy/backend/domain/follow/service/FollowServiceTest.java b/backend/src/test/java/com/graphy/backend/domain/follow/service/FollowServiceTest.java index 8d8805b6..633e5ed4 100644 --- a/backend/src/test/java/com/graphy/backend/domain/follow/service/FollowServiceTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/follow/service/FollowServiceTest.java @@ -5,6 +5,8 @@ import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.member.dto.response.GetMemberListResponse; import com.graphy.backend.domain.member.repository.MemberRepository; +import com.graphy.backend.domain.member.service.MemberService; +import com.graphy.backend.domain.notification.service.NotificationService; import com.graphy.backend.global.error.exception.AlreadyExistException; import com.graphy.backend.global.error.exception.EmptyResultException; import com.graphy.backend.test.MockTest; @@ -21,8 +23,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -31,12 +32,18 @@ class FollowServiceTest extends MockTest { FollowRepository followRepository; @Mock MemberRepository memberRepository; + + @Mock + NotificationService notificationService; + @Mock + MemberService memberService; + @InjectMocks FollowService followService; @Test @DisplayName("팔로우 신청 테스트") - void followTest() throws Exception { + void followTest() { //given Member fromMember = Member.builder().id(1L).build(); Long toId = 2L; @@ -44,6 +51,9 @@ void followTest() throws Exception { //when doNothing().when(memberRepository).increaseFollowingCount(fromMember.getId()); doNothing().when(memberRepository).increaseFollowerCount(toId); + doNothing().when(notificationService).addNotification(any(), any()); + when(memberService.findMemberById(fromMember.getId())).thenReturn(fromMember); + followService.addFollow(toId, fromMember); //then @@ -52,7 +62,7 @@ void followTest() throws Exception { @Test @DisplayName("팔로잉 리스트 조회 테스트") - void getFollowingListTest() throws Exception { + void getFollowingListTest() { //given Member fromMember = Member.builder().id(1L).build(); GetMemberListResponse following1 = new GetMemberListResponse() { @@ -87,7 +97,7 @@ public String getNickname() { @Test @DisplayName("팔로워 리스트 조회 테스트") - void getFollowerListTest() throws Exception { + void getFollowerListTest() { //given Member toMember = Member.builder().id(1L).build(); GetMemberListResponse follower1 = new GetMemberListResponse() { @@ -122,7 +132,7 @@ public String getNickname() { @Test @DisplayName("언팔로우 테스트") - void unfollowTest() throws Exception { + void unfollowTest() { //given Long toId = 1L; Member fromMember = Member.builder().id(2L).build(); @@ -149,31 +159,28 @@ void unfollowNotFoundTest() { .thenReturn(Optional.empty()); // when - Exception exception = assertThrows(EmptyResultException.class, () -> { - followService.removeFollow(toId, fromMember); - }); + Exception exception = assertThrows(EmptyResultException.class, () -> followService.removeFollow(toId, fromMember)); // then String exceptionMessage = exception.getMessage(); - assertTrue(exceptionMessage.equals("존재하지 않는 팔로우")); + assertEquals("존재하지 않는 팔로우", exceptionMessage); } @Test @DisplayName("팔로우 여부 체크 테스트") - void followingCheckTest() throws Exception { + void followingCheckTest() { // given when(followRepository.existsByFromIdAndToId(1L, 2L)).thenReturn(true); when(followRepository.existsByFromIdAndToId(3L, 4L)).thenReturn(false); // when & then - Member loginUser = new Member(); assertThatThrownBy( - () -> followService.checkFollowingAlready(1L, 2L)) + () -> followService.checkFollowAvailable(1L, 2L)) .isInstanceOf(AlreadyExistException.class) .hasMessageContaining("이미 존재하는 팔로우"); - Assertions.assertThatCode(() -> followService.checkFollowingAlready(3L, 4L)) + Assertions.assertThatCode(() -> followService.checkFollowAvailable(3L, 4L)) .doesNotThrowAnyException(); } } diff --git a/backend/src/test/java/com/graphy/backend/domain/job/controller/JobControllerTest.java b/backend/src/test/java/com/graphy/backend/domain/job/controller/JobControllerTest.java index 61adb0b3..6337e2ab 100644 --- a/backend/src/test/java/com/graphy/backend/domain/job/controller/JobControllerTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/job/controller/JobControllerTest.java @@ -4,8 +4,6 @@ import com.graphy.backend.domain.job.service.JobService; import com.graphy.backend.test.MockApiTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -14,13 +12,6 @@ import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.web.context.WebApplicationContext; -import static org.mockito.Mockito.*; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(JobController.class) @ExtendWith(RestDocumentationExtension.class) @@ -34,29 +25,4 @@ class JobControllerTest extends MockApiTest { public void setup(RestDocumentationContextProvider provider) { this.mvc = buildMockMvc(context, provider); } - - - @Test - @DisplayName("공고를 저장한다.") - void saveJobInfo() throws Exception { - doNothing().when(jobService).save(); - mvc.perform(post("/api/v1/jobs")) - .andExpect(status().isOk()) - .andDo(document("JobInfo-Save", - preprocessResponse(prettyPrint())) - ); - verify(jobService, times(1)).save(); - } - - @Test - @DisplayName("공고를 삭제한다.") - public void deleteJobInfo() throws Exception { - doNothing().when(jobService).deleteExpiredJobs(); - mvc.perform(delete("/api/v1/jobs")) - .andExpect(status().isOk()) - .andDo(document("JobInfo-Delete", - preprocessResponse(prettyPrint())) - ); - verify(jobService, times(1)).deleteExpiredJobs(); - } } diff --git a/backend/src/test/java/com/graphy/backend/domain/job/service/JobServiceTest.java b/backend/src/test/java/com/graphy/backend/domain/job/service/JobServiceTest.java index 70e12870..78f532a6 100644 --- a/backend/src/test/java/com/graphy/backend/domain/job/service/JobServiceTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/job/service/JobServiceTest.java @@ -1,25 +1,12 @@ package com.graphy.backend.domain.job.service; -import com.graphy.backend.domain.job.domain.Job; import com.graphy.backend.domain.job.repository.JobRepository; -import com.graphy.backend.test.MockApiTest; import com.graphy.backend.test.MockTest; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.time.LocalDateTime; - -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) public class JobServiceTest extends MockTest { @InjectMocks @@ -28,14 +15,4 @@ public class JobServiceTest extends MockTest { @Mock JobRepository jobRepository; - - @Test - @DisplayName("공고가 삭제된다.") - public void saveTest() throws Exception { - doNothing().when(jobRepository).deleteAllExpiredSince(any(LocalDateTime.class)); - - jobService.deleteExpiredJobs(); - - verify(jobRepository, times(1)).deleteAllExpiredSince(any(LocalDateTime.class)); - } } diff --git a/backend/src/test/java/com/graphy/backend/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/graphy/backend/domain/member/controller/MemberControllerTest.java index 0a3e9da5..1855b6f7 100644 --- a/backend/src/test/java/com/graphy/backend/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/member/controller/MemberControllerTest.java @@ -6,6 +6,7 @@ import com.graphy.backend.domain.member.dto.response.GetMyPageResponse; import com.graphy.backend.domain.member.service.MemberService; import com.graphy.backend.domain.project.dto.response.GetProjectInfoResponse; +import com.graphy.backend.domain.project.service.ProjectService; import com.graphy.backend.test.MockApiTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,10 +24,11 @@ import java.util.List; import static org.hamcrest.Matchers.hasSize; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -41,7 +43,10 @@ class MemberControllerTest extends MockApiTest { private WebApplicationContext context; @MockBean MemberService memberService; - private static String BASE_URL = "/api/v1/members"; + @MockBean + ProjectService projectService; + + private static final String BASE_URL = "/api/v1/members"; private Member member1; private Member member2; @@ -129,13 +134,13 @@ void getMyPageTest() throws Exception { .followerCount(member1.getFollowerCount()) .followingCount(member1.getFollowingCount()) .getProjectInfoResponseList(Arrays.asList( - GetProjectInfoResponse.builder().id(1L).projectName("project1").description("description1").build(), - GetProjectInfoResponse.builder().id(2L).projectName("project2").description("description2").build() + GetProjectInfoResponse.builder().id(1L).projectName("project1").content("content1").build(), + GetProjectInfoResponse.builder().id(2L).projectName("project2").content("content2").build() )) .build(); // when - when(memberService.myPage(any())).thenReturn(result); + when(projectService.myPage(any())).thenReturn(result); // then @@ -152,11 +157,11 @@ void getMyPageTest() throws Exception { .andExpect(jsonPath("$.data.getProjectInfoResponseList[0].id").value(1)) .andExpect(jsonPath("$.data.getProjectInfoResponseList[0].projectName").value("project1")) - .andExpect(jsonPath("$.data.getProjectInfoResponseList[0].description").value("description1")) + .andExpect(jsonPath("$.data.getProjectInfoResponseList[0].content").value("content1")) .andExpect(jsonPath("$.data.getProjectInfoResponseList[1].id").value(2)) .andExpect(jsonPath("$.data.getProjectInfoResponseList[1].projectName").value("project2")) - .andExpect(jsonPath("$.data.getProjectInfoResponseList[1].description").value("description2")) + .andExpect(jsonPath("$.data.getProjectInfoResponseList[1].content").value("content2")) .andDo(document("members/myPage/find/success", responseFields( @@ -172,7 +177,7 @@ void getMyPageTest() throws Exception { fieldWithPath("data.getProjectInfoResponseList[].id").description("프로젝트 ID"), fieldWithPath("data.getProjectInfoResponseList[].projectName").description("프로젝트 이름"), - fieldWithPath("data.getProjectInfoResponseList[].description").description("프로젝트 설명") + fieldWithPath("data.getProjectInfoResponseList[].content").description("프로젝트 소개") ))); } } diff --git a/backend/src/test/java/com/graphy/backend/domain/member/repository/MemberRepositoryTest.java b/backend/src/test/java/com/graphy/backend/domain/member/repository/MemberRepositoryTest.java index 3b5e64de..ab45657b 100644 --- a/backend/src/test/java/com/graphy/backend/domain/member/repository/MemberRepositoryTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/member/repository/MemberRepositoryTest.java @@ -23,8 +23,8 @@ JpaAuditingConfig.class, QueryDslConfig.class }) -@ActiveProfiles(TestProfile.TEST) -public class MemberRepositoryTest { +@ActiveProfiles(TestProfile.UNIT) +class MemberRepositoryTest { @Autowired MemberRepository memberRepository; @PersistenceContext @@ -63,7 +63,7 @@ public void setup() { @Test @DisplayName("팔로워를 1 증가시킨다") - public void increaseFollowerCountTest() throws Exception { + void increaseFollowerCountTest() throws Exception { // given int 기존_팔로워_수 = member.getFollowerCount(); @@ -78,7 +78,7 @@ public void increaseFollowerCountTest() throws Exception { @Test @DisplayName("팔로워를 1 감소시킨다") - public void decreaseFollowerCountTest() throws Exception { + void decreaseFollowerCountTest() throws Exception { // given int 기존_팔로워_수 = member.getFollowerCount(); @@ -93,7 +93,7 @@ public void decreaseFollowerCountTest() throws Exception { @Test @DisplayName("팔로워가 0인 경우 감소시키지 않는다") - public void decreaseFollowerCountZeroFollowerTest() throws Exception { + void decreaseFollowerCountZeroFollowerTest() throws Exception { // given int 기존_팔로워_수 = 팔로우_팔로워가_0인_사용자.getFollowerCount(); @@ -109,7 +109,7 @@ public void decreaseFollowerCountZeroFollowerTest() throws Exception { @Test @DisplayName("팔로잉을 1 증가시킨다") - public void increaseFollowingCountTest() throws Exception { + void increaseFollowingCountTest() throws Exception { // given int 기존_팔로잉_수 = member.getFollowingCount(); @@ -124,7 +124,7 @@ public void increaseFollowingCountTest() throws Exception { @Test @DisplayName("팔로잉을 1 감소시킨다") - public void decreaseFollowingCountTest() throws Exception { + void decreaseFollowingCountTest() throws Exception { // given int 기존_팔로잉_수 = member.getFollowingCount(); @@ -139,9 +139,7 @@ public void decreaseFollowingCountTest() throws Exception { @Test @DisplayName("팔로잉이 0인 경우 감소시키지 않는다") - public void decreaseFollowingCountZeroFollowingTest() throws Exception { - // given - int 기존_팔로잉_수 = 팔로우_팔로워가_0인_사용자.getFollowingCount(); + void decreaseFollowingCountZeroFollowingTest() throws Exception { // when memberRepository.decreaseFollowingCount(팔로우_팔로워가_0인_사용자.getId()); diff --git a/backend/src/test/java/com/graphy/backend/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/graphy/backend/domain/member/service/MemberServiceTest.java index 5e3d2394..a19e5b2d 100644 --- a/backend/src/test/java/com/graphy/backend/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/member/service/MemberServiceTest.java @@ -3,10 +3,7 @@ import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.member.domain.Role; import com.graphy.backend.domain.member.dto.response.GetMemberResponse; -import com.graphy.backend.domain.member.dto.response.GetMyPageResponse; import com.graphy.backend.domain.member.repository.MemberRepository; -import com.graphy.backend.domain.project.dto.response.GetProjectInfoResponse; -import com.graphy.backend.domain.project.service.ProjectService; import com.graphy.backend.global.error.exception.AlreadyExistException; import com.graphy.backend.global.error.exception.EmptyResultException; import com.graphy.backend.test.MockTest; @@ -18,7 +15,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; @@ -32,8 +32,6 @@ class MemberServiceTest extends MockTest { private MemberService memberService; @Mock private MemberRepository memberRepository; - @Mock - private ProjectService projectService; private Member member1; private Member member2; @@ -62,7 +60,7 @@ public void setup() { @Test @DisplayName("닉네임으로 사용자 목록을 조회한다") - public void findMemberListTest() throws Exception { + void findMemberListTest() { // given List memberList = Arrays.asList(member1, member2); @@ -78,7 +76,7 @@ public void findMemberListTest() throws Exception { @Test @DisplayName("닉네임으로 사용자 목록을 조회 시 일치하는 사용자가 없으면 빈 목록이 반환된다") - public void findMemberListEmptyListTest() throws Exception { + void findMemberListEmptyListTest() { // given String 존재하지_않는_닉네임 = "emptyListNickname"; @@ -94,7 +92,7 @@ public void findMemberListEmptyListTest() throws Exception { @Test @DisplayName("사용자 ID로 사용자를 조회한다") - public void findMemberByIdTest() throws Exception { + void findMemberByIdTest() { // when when(memberRepository.findById(member1.getId())).thenReturn(Optional.ofNullable(member1)); Member actual = memberService.findMemberById(member1.getId()); @@ -105,7 +103,7 @@ public void findMemberByIdTest() throws Exception { @Test @DisplayName("사용자 ID로 사용자를 조회 시 사용자가 존재하지 않으면 예외가 발생한다") - public void findMemberByIdNotExistMemberExceptionTest() throws Exception { + void findMemberByIdNotExistMemberExceptionTest() { // given Long 존재하지_않는_사용자_ID = 0L; @@ -116,42 +114,9 @@ public void findMemberByIdNotExistMemberExceptionTest() throws Exception { .hasMessageContaining("존재하지 않는 사용자"); } - @Test - @DisplayName("현재 로그인한 사용자를 상세 조회한다") - public void myPageTest() throws Exception { - // given - GetProjectInfoResponse response1 = GetProjectInfoResponse.builder() - .id(1L) - .projectName("project1") - .description("description1") - .build(); - - GetProjectInfoResponse response2 = GetProjectInfoResponse.builder() - .id(2L) - .projectName("project2") - .description("description2") - .build(); - - List responseList = Arrays.asList(response1, response2); - - // when - when(projectService.findProjectInfoList(member1.getId())).thenReturn(responseList); - GetMyPageResponse actual = memberService.myPage(member1); - - // then - assertThat(actual.getNickname()).isEqualTo(member1.getNickname()); - assertThat(actual.getIntroduction()).isEqualTo(member1.getIntroduction()); - assertThat(actual.getFollowerCount()).isEqualTo(member1.getFollowerCount()); - assertThat(actual.getFollowingCount()).isEqualTo(member1.getFollowingCount()); - - assertThat(actual.getGetProjectInfoResponseList()) - .usingRecursiveComparison() - .isEqualTo(responseList); - } - @Test @DisplayName("이메일이 중복된 경우 예외가 발생한다") - public void checkEmailDuplicateTest() throws Exception { + void checkEmailDuplicateTest() { // given String 중복된_이메일 = member1.getEmail(); @@ -167,7 +132,7 @@ public void checkEmailDuplicateTest() throws Exception { @Test @DisplayName("사용자를 저장한다") - public void addMemberTest() throws Exception { + void addMemberTest() { // when, then assertDoesNotThrow(() -> memberService.addMember(member1)); } diff --git a/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectControllerTest.java b/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectControllerTest.java index 7d50b6bf..ab525f33 100644 --- a/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectControllerTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectControllerTest.java @@ -1,6 +1,5 @@ package com.graphy.backend.domain.project.controller; -import com.graphy.backend.domain.comment.service.CommentService; import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.project.dto.request.CreateProjectRequest; import com.graphy.backend.domain.project.dto.request.GetProjectPlanRequest; @@ -57,10 +56,6 @@ class ProjectControllerTest extends MockApiTest { private WebApplicationContext context; @MockBean ProjectService projectService; - - @MockBean - CommentService commentService; - private static final String BASE_URL = "/api/v1/projects"; @BeforeEach diff --git a/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectIntegrationTest.java b/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectIntegrationTest.java new file mode 100644 index 00000000..5c69dcdf --- /dev/null +++ b/backend/src/test/java/com/graphy/backend/domain/project/controller/ProjectIntegrationTest.java @@ -0,0 +1,46 @@ +package com.graphy.backend.domain.project.controller; + +import com.graphy.backend.domain.project.dto.request.CreateProjectRequest; +import com.graphy.backend.test.util.IntegrationTest; +import com.graphy.backend.test.util.WithMockCustomUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ProjectIntegrationTest extends IntegrationTest { + + @Autowired + private WebApplicationContext context; + + private static final String BASE_URL = "/api/v1/projects"; + + @BeforeEach + public void setup() { + this.mvc = this.buildMockMvc(context); + } + + @Test + @WithMockCustomUser + @DisplayName("프로젝트 생성 테스트") + void createProject() throws Exception { + //given + CreateProjectRequest request = CreateProjectRequest.builder() + .projectName("projectName") + .description("description") + .content("content") + .build(); + + //then + mvc.perform(post(BASE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + +} diff --git a/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java b/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java index fc058323..98a44b8f 100644 --- a/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java @@ -3,13 +3,16 @@ import com.graphy.backend.domain.auth.service.CustomUserDetailsService; import com.graphy.backend.domain.member.domain.Member; import com.graphy.backend.domain.member.domain.Role; +import com.graphy.backend.domain.member.dto.response.GetMyPageResponse; import com.graphy.backend.domain.project.domain.Project; import com.graphy.backend.domain.project.domain.ProjectTags; import com.graphy.backend.domain.project.domain.Tag; +import com.graphy.backend.domain.project.domain.Tags; import com.graphy.backend.domain.project.dto.request.CreateProjectRequest; import com.graphy.backend.domain.project.dto.request.GetProjectsRequest; import com.graphy.backend.domain.project.dto.request.UpdateProjectRequest; import com.graphy.backend.domain.project.dto.response.CreateProjectResponse; +import com.graphy.backend.domain.project.dto.response.GetProjectInfoResponse; import com.graphy.backend.domain.project.dto.response.GetProjectResponse; import com.graphy.backend.domain.project.dto.response.UpdateProjectResponse; import com.graphy.backend.domain.project.repository.ProjectRepository; @@ -70,29 +73,30 @@ void updateProject() throws Exception { .content("content") .build(); + List techTags = new ArrayList<>(Arrays.asList("Spring", "Django")); + UpdateProjectRequest request = UpdateProjectRequest.builder() .projectName("afterUpdate") .description("des") .thumbNail("thumb") .content("content") - .techTags(new ArrayList<>(Arrays.asList("Spring", "Django"))) + .techTags(techTags) .build(); - Tag tag1 = Tag.builder().tech("Spring").build(); - Tag tag2 = Tag.builder().tech("Django").build(); + Tag tag1 = Tag.builder().tech("Vue").build(); + Tag tag2 = Tag.builder().tech("Java").build(); //when when(projectRepository.findById(project.getId())).thenReturn(Optional.of(project)); - when(tagService.findTagByTech("Spring")).thenReturn(tag1); - when(tagService.findTagByTech("Django")).thenReturn(tag2); + when(tagService.findTagListByName(techTags)).thenReturn(new Tags(List.of(tag1, tag2))); UpdateProjectResponse result = projectService.modifyProject(project.getId(), request); assertThat(result.getProjectName()).isEqualTo(project.getProjectName()); assertThat(result.getDescription()).isEqualTo(project.getDescription()); assertThat(result.getThumbNail()).isEqualTo(project.getThumbNail()); - assertThat(result.getTechTags()).isEqualTo(new ArrayList<>(Arrays.asList("Spring", "Django"))); + assertThat(result.getTechTags()).isEqualTo(new ArrayList<>(Arrays.asList("Vue", "Java"))); } @Test @@ -115,6 +119,10 @@ void createProject() throws Exception { //when when(projectRepository.save(any(Project.class))).thenReturn(project); + when(tagService.findTagListByName(techTags)).thenReturn(new Tags(List.of( + Tag.builder().tech("Spring").build(), + Tag.builder().tech("Django").build() + ))); CreateProjectResponse result = projectService.addProject(request, member); //then @@ -174,10 +182,70 @@ void deleteProject() throws Exception { //then verify(projectRepository).deleteById(1L); } + @Test + @DisplayName("현재 로그인한 사용자를 상세 조회한다") + void myPageTest() { + // given + Member member1 = Member.builder() + .id(1L) + .email("email1@gmail.com") + .nickname("name1") + .introduction("introduction1") + .followingCount(10) + .followerCount(11) + .role(Role.ROLE_USER) + .build(); + + Project project = Project.builder() + .id(1L) + .projectTags(new ProjectTags()) + .projectName("project1") + .description("description1") + .thumbNail("thumb") + .content("content1") + .build(); + + Project project2 = Project.builder() + .id(2L) + .projectTags(new ProjectTags()) + .projectName("project2") + .description("description2") + .thumbNail("thumb") + .content("content2") + .build(); + + GetProjectInfoResponse response1 = GetProjectInfoResponse.builder() + .id(1L) + .projectName("project1") + .content("content1") + .build(); + + GetProjectInfoResponse response2 = GetProjectInfoResponse.builder() + .id(2L) + .projectName("project2") + .content("content2") + .build(); + + List responseList = Arrays.asList(response1, response2); + + // when + when(projectRepository.findByMemberId(member1.getId())).thenReturn(List.of(project, project2)); + GetMyPageResponse actual = projectService.myPage(member1); + + // then + assertThat(actual.getNickname()).isEqualTo(member1.getNickname()); + assertThat(actual.getIntroduction()).isEqualTo(member1.getIntroduction()); + assertThat(actual.getFollowerCount()).isEqualTo(member1.getFollowerCount()); + assertThat(actual.getFollowingCount()).isEqualTo(member1.getFollowingCount()); + + assertThat(actual.getGetProjectInfoResponseList()) + .usingRecursiveComparison() + .isEqualTo(responseList); + } @Test @DisplayName("프로젝트 조회 시 존재하지 않는 프로젝트 예외 처리") - public void ProjectNotExistError() { + void ProjectNotExistError() { // given Long projectId = 1L; @@ -192,7 +260,7 @@ public void ProjectNotExistError() { @Test @DisplayName("프로젝트 삭제 시 존재하지 않는 프로젝트 예외 처리") - public void EmptyResultDataAccessException() throws Exception { + void EmptyResultDataAccessException() throws Exception { // given doThrow(EmptyResultDataAccessException.class).when(projectRepository).deleteById(anyLong()); diff --git a/backend/src/test/java/com/graphy/backend/domain/recruitment/controller/RecruitmentControllerTest.java b/backend/src/test/java/com/graphy/backend/domain/recruitment/controller/RecruitmentControllerTest.java new file mode 100644 index 00000000..8f6c0324 --- /dev/null +++ b/backend/src/test/java/com/graphy/backend/domain/recruitment/controller/RecruitmentControllerTest.java @@ -0,0 +1,119 @@ +package com.graphy.backend.domain.recruitment.controller; + +import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.member.domain.Role; +import com.graphy.backend.domain.recruitment.domain.Position; +import com.graphy.backend.domain.recruitment.domain.ProcessType; +import com.graphy.backend.domain.recruitment.domain.Recruitment; +import com.graphy.backend.domain.recruitment.dto.request.CreateRecruitmentRequest; +import com.graphy.backend.domain.recruitment.service.RecruitmentService; +import com.graphy.backend.global.config.SecurityConfig; +import com.graphy.backend.test.MockApiTest; +import com.graphy.backend.test.util.WithMockCustomUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.graphy.backend.test.config.ApiDocumentUtil.getDocumentRequest; +import static com.graphy.backend.test.config.ApiDocumentUtil.getDocumentResponse; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RecruitmentController.class) +@ExtendWith(RestDocumentationExtension.class) +@Import(SecurityConfig.class) +@WithMockCustomUser +public class RecruitmentControllerTest extends MockApiTest { + + @Autowired + private WebApplicationContext context; + @MockBean + RecruitmentService recruitmentService; + private Member member; + private Recruitment recruitment; + private static final String BASE_URL = "/api/v1/recruitments"; + + @BeforeEach + public void setup(RestDocumentationContextProvider provider) { + member = Member.builder() + .id(1L) + .email("graphy@gmail.com") + .nickname("name") + .role(Role.ROLE_USER) + .build(); + + recruitment = Recruitment.builder() + .member(member) + .title("title") + .content("content") + .type(ProcessType.ONLINE) + .endDate(LocalDateTime.now()) + .period(LocalDateTime.now()) + .position(Position.BACKEND) + .recruitmentCount(3) + .build(); + + this.mvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(documentationConfiguration(provider)) + .build(); + } + @Test + @DisplayName("구인 게시글 생성 테스트") + void recruitmentAddTest() throws Exception { + // given + CreateRecruitmentRequest request = CreateRecruitmentRequest.builder() + .title("title") + .content("content") + .type(ProcessType.ONLINE) + .endDate(LocalDateTime.now()) + .period(LocalDateTime.now()) + .position(Position.BACKEND) + .recruitmentCount(3) + .techTags(List.of("Spring", "Vue", "Docker")) + .build(); + + + // when, then + mvc.perform(post(BASE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andDo(print()) + .andDo(document("recruitment/add/success", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("title").description("구인 게시글 제목"), + fieldWithPath("content").description("구인 게시글 내용"), + fieldWithPath("type").description("프로젝트 진행 방식"), + fieldWithPath("endDate").description("구인 게시글 마감 일정"), + fieldWithPath("period").description("프로젝트 진행 기간"), + fieldWithPath("position").description("모집 분야"), + fieldWithPath("recruitmentCount").description("모집 인원"), + fieldWithPath("techTags").description("기술 스택") + ), + responseFields( + fieldWithPath("code").description("상태 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data").description("응답 데이터") + ))); + } +} diff --git a/backend/src/test/java/com/graphy/backend/test/MockApiTest.java b/backend/src/test/java/com/graphy/backend/test/MockApiTest.java index d9029740..f0856eb6 100644 --- a/backend/src/test/java/com/graphy/backend/test/MockApiTest.java +++ b/backend/src/test/java/com/graphy/backend/test/MockApiTest.java @@ -6,7 +6,6 @@ import com.graphy.backend.domain.auth.infra.TokenProvider; import com.graphy.backend.domain.auth.repository.RefreshTokenRepository; import com.graphy.backend.test.config.TestProfile; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.restdocs.RestDocumentationContextProvider; @@ -22,7 +21,6 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = BackendApplication.class) @ActiveProfiles(TestProfile.TEST) -@Disabled public abstract class MockApiTest { protected MockMvc mvc; diff --git a/backend/src/test/java/com/graphy/backend/test/MockTest.java b/backend/src/test/java/com/graphy/backend/test/MockTest.java index 247de26d..0ca84ce4 100644 --- a/backend/src/test/java/com/graphy/backend/test/MockTest.java +++ b/backend/src/test/java/com/graphy/backend/test/MockTest.java @@ -7,8 +7,8 @@ import org.springframework.test.context.ActiveProfiles; @ExtendWith(MockitoExtension.class) -@ActiveProfiles(TestProfile.TEST) +@ActiveProfiles(TestProfile.UNIT) @Disabled -public class MockTest { +public abstract class MockTest { } diff --git a/backend/src/test/java/com/graphy/backend/test/config/TestProfile.java b/backend/src/test/java/com/graphy/backend/test/config/TestProfile.java index 4d7f1a05..11fcd5ca 100644 --- a/backend/src/test/java/com/graphy/backend/test/config/TestProfile.java +++ b/backend/src/test/java/com/graphy/backend/test/config/TestProfile.java @@ -2,4 +2,5 @@ public interface TestProfile { String TEST = "test"; + String UNIT = "unit"; } diff --git a/backend/src/test/java/com/graphy/backend/test/util/IntegrationTest.java b/backend/src/test/java/com/graphy/backend/test/util/IntegrationTest.java new file mode 100644 index 00000000..47eb4f06 --- /dev/null +++ b/backend/src/test/java/com/graphy/backend/test/util/IntegrationTest.java @@ -0,0 +1,37 @@ +package com.graphy.backend.test.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.graphy.backend.test.config.TestProfile; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +@ActiveProfiles(TestProfile.TEST) +public abstract class IntegrationTest { + + protected MockMvc mvc; + protected ObjectMapper objectMapper = buildObjectMapper(); + + static final String MYSQL_IMAGE = "mysql:8"; + @Container + static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer(MYSQL_IMAGE); + + public MockMvc buildMockMvc(WebApplicationContext context) { + return MockMvcBuilders.webAppContextSetup(context) + .build(); + } + + private ObjectMapper buildObjectMapper() { + final ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-unit.yml similarity index 100% rename from backend/src/test/resources/application-test.yml rename to backend/src/test/resources/application-unit.yml diff --git a/backend/src/test/resources/logback-test.xml b/backend/src/test/resources/logback-test.xml new file mode 100644 index 00000000..40ee1ab0 --- /dev/null +++ b/backend/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + + + diff --git a/backend/src/test/resources/schema.sql b/backend/src/test/resources/schema.sql new file mode 100644 index 00000000..93578f08 --- /dev/null +++ b/backend/src/test/resources/schema.sql @@ -0,0 +1,186 @@ +/*----------------------------------------------- CREATE TABLE -----------------------------------------------*/ +create table follow +( + id bigint auto_increment + primary key, + from_id bigint null, + to_id bigint null, + constraint following_unique + unique (from_id, to_id) +); + +create table job +( + job_id bigint not null + primary key, + company_name varchar(255) not null, + expiration_date datetime(6) not null, + title varchar(255) not null, + url varchar(255) not null +); + +create table member +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + is_deleted bit not null, + updated_at datetime(6) null, + email varchar(255) not null, + follower_count int default 0 null, + following_count int default 0 null, + introduction varchar(255) null, + nickname varchar(255) not null, + password varchar(255) not null, + role varchar(255) null +); + +create table project +( + project_id bigint auto_increment + primary key, + created_at datetime(6) null, + is_deleted bit not null, + updated_at datetime(6) null, + content longtext null, + description varchar(255) null, + like_count int default 0 null, + project_name varchar(255) not null, + thumb_nail varchar(255) null, + member_id bigint not null, + constraint FKf02mrsqr7qo2g4pi5oetixtf1 + foreign key (member_id) references member (id) +); + +create table comment +( + comment_id bigint auto_increment + primary key, + created_at datetime(6) null, + is_deleted bit not null, + updated_at datetime(6) null, + content varchar(255) not null, + member_id bigint not null, + parent_id bigint null, + project_id bigint not null, + constraint FKb5kenf6fjka6ck0snroeb5tmh + foreign key (project_id) references project (project_id), + constraint FKde3rfu96lep00br5ov0mdieyt + foreign key (parent_id) references comment (comment_id), + constraint FKmrrrpi513ssu63i2783jyiv9m + foreign key (member_id) references member (id) +); + +create table likes +( + like_id bigint auto_increment + primary key, + created_at datetime(6) null, + is_deleted bit not null, + updated_at datetime(6) null, + member_id bigint not null, + project_id bigint not null, + constraint FK6gupou17or1xfkb1mkasgwqys + foreign key (project_id) references project (project_id), + constraint FKa4vkf1skcfu5r6o5gfb5jf295 + foreign key (member_id) references member (id) +); + +create table tag +( + id bigint auto_increment + primary key, + tech varchar(255) not null +); + +create table project_tag +( + project_tag_id bigint auto_increment + primary key, + project_id bigint null, + tag_id bigint null, + constraint FK519h89u5tkrcmyquqgr5lh3y2 + foreign key (tag_id) references tag (id), + constraint FKk3ccabfs72wkx2008pn7tij9b + foreign key (project_id) references project (project_id) +); + +/* ----------------------------------------------- INSERT PROJECT_TAG ----------------------------------------------- */ +insert into tag (id, tech) +values (1, 'JavaScript'); +insert into tag (id, tech) +values (2, 'Java'); +insert into tag (id, tech) +values (3, 'TypeScript'); +insert into tag (id, tech) +values (4, 'React'); +insert into tag (id, tech) +values (5, 'Nextjs'); +insert into tag (id, tech) +values (6, 'Spring'); +insert into tag (id, tech) +values (7, 'GraphQL'); +insert into tag (id, tech) +values (8, 'Redis'); + + +/* ----------------------------------------------- INSERT MEMBER ----------------------------------------------- +# Member ID: string[PK값] ex)string1 +# Member Password: string[PK값] ex)string1 + */ + +insert into member (id, created_at, is_deleted, updated_at, email, follower_count, following_count, introduction, nickname, password, role) +values + (1,'2023-09-26 22:57:00.979022',false,'2023-09-26 22:57:00.979022','string1@gmail.com', 0, 0,'string1','string1','$2a$10$bas9UT/DZbPyaQpjV17nnO/s.lLJqg4PPUoFxV51/oAi7tuhF1UyW','ROLE_USER'); +insert into member (id, created_at, is_deleted, updated_at, email, follower_count, following_count, introduction, nickname, password, role) +values + (2,'2023-09-26 22:57:00.979022',false,'2023-09-26 22:57:00.979022','string2@gmail.com', 0, 0,'string2','string2','$2a$10$bas9UT/DZbPyaQpjV17nnO/s.lLJqg4PPUoFxV51/oAi7tuhF1UyW','ROLE_USER'); +insert into member (id, created_at, is_deleted, updated_at, email, follower_count, following_count, introduction, nickname, password, role) +values + (3,'2023-09-26 22:57:00.979022',false,'2023-09-26 22:57:00.979022','string3@gmail.com', 0, 0,'string3','string2','$2a$10$bas9UT/DZbPyaQpjV17nnO/s.lLJqg4PPUoFxV51/oAi7tuhF1UyW','ROLE_USER'); +insert into member (id, created_at, is_deleted, updated_at, email, follower_count, following_count, introduction, nickname, password, role) +values + (4,'2023-09-26 22:57:00.979022',false,'2023-09-26 22:57:00.979022','string4@gmail.com', 0, 0,'string4','string2','$2a$10$bas9UT/DZbPyaQpjV17nnO/s.lLJqg4PPUoFxV51/oAi7tuhF1UyW','ROLE_USER'); + + +/* ----------------------------------------------- INSERT PROJECT ----------------------------------------------- */ +insert into project (project_id, created_at, is_deleted, updated_at, content, description, like_count, project_name, + thumb_nail, member_id) +values (1, '2023-09-26 22:57:00.979022', false, '2023-09-26 22:57:00.979022', 'string1', 'string1', 0, 'string1', + 'string1', 1); +insert into project (project_id, created_at, is_deleted, updated_at, content, description, like_count, project_name, + thumb_nail, member_id) +values (2, '2023-09-26 22:57:00.979022', false, '2023-09-26 22:57:00.979022', 'string2', 'string2', 0, 'string2', + 'string2', 2); + +insert into project (project_id, created_at, is_deleted, updated_at, content, description, like_count, project_name, + thumb_nail, member_id) +values (3, '2023-09-26 22:57:00.979022', false, '2023-09-26 22:57:00.979022', 'string3', 'string3', 0, 'string3', + 'string3', 1); +insert into project (project_id, created_at, is_deleted, updated_at, content, description, like_count, project_name, + thumb_nail, member_id) +values (4, '2023-09-26 22:57:00.979022', false, '2023-09-26 22:57:00.979022', 'string4', 'string4', 0, 'string4', + 'string4', 2); + +insert into project (project_id, created_at, is_deleted, updated_at, content, description, like_count, project_name, + thumb_nail, member_id) +values (5, '2023-09-26 22:57:00.979022', false, '2023-09-26 22:57:00.979022', 'string5', 'string5', 0, 'string5', + 'string5', 3); +insert into project (project_id, created_at, is_deleted, updated_at, content, description, like_count, project_name, + thumb_nail, member_id) +values (6, '2023-09-26 22:57:00.979022', false, '2023-09-26 22:57:00.979022', 'string6', 'string6', 0, 'string6', + 'string6', 3); + +/* ----------------------------------------------- INSERT PROJECT_TAG ----------------------------------------------- */ +insert into project_tag (project_tag_id, project_id, tag_id) +values (1, 1, 1); +insert into project_tag (project_tag_id, project_id, tag_id) +values (2, 1, 2); +insert into project_tag (project_tag_id, project_id, tag_id) +values (3, 2, 3); +insert into project_tag (project_tag_id, project_id, tag_id) +values (4, 2, 4); +insert into project_tag (project_tag_id, project_id, tag_id) +values (5, 3, 5); +insert into project_tag (project_tag_id, project_id, tag_id) +values (6, 3, 6); \ No newline at end of file diff --git a/crawling_python/Dockerfile b/crawling_python/Dockerfile new file mode 100644 index 00000000..c41e74d9 --- /dev/null +++ b/crawling_python/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11 + +RUN python -m venv /venv +ENV PATH="/venv/bin:$PATH" + +COPY . /app/ +WORKDIR /app + +RUN apt-get update && apt-get install -y chromium chromium-driver + +RUN pip install --no-cache-dir -r requirements.txt + +CMD ["python", "scheduler.py"] diff --git a/crawling_python/crawling_jobkorea.py b/crawling_python/crawling_jobkorea.py new file mode 100644 index 00000000..143bcce6 --- /dev/null +++ b/crawling_python/crawling_jobkorea.py @@ -0,0 +1,131 @@ +import re +import time +from datetime import datetime, timedelta + +from global_utils import * +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from dotenv import load_dotenv +from selenium.webdriver.support.wait import WebDriverWait + +load_dotenv() + +def crawling_job_data(driver, page_number, existing_contents): + url = f'https://www.jobkorea.co.kr/Recruit/Joblist?menucode=local&localorder=1#anchorGICnt_{page_number}' + driver.get(url) + wait = WebDriverWait(driver, 10) + + if page_number == 1: + duty_btn = driver.find_element(By.CSS_SELECTOR, 'p.btn_tit') + duty_btn.click() + + dev_data_label = driver.find_element(By.CSS_SELECTOR, 'label[for="duty_step1_10031"]') + dev_data_label.click() + + backend_dev = driver.find_element(By.XPATH, '//span[contains(text(), "백엔드개발자")]') + backend_dev.click() + + frontend_dev = driver.find_element(By.XPATH, '//span[contains(text(), "프론트엔드개발자")]') + frontend_dev.click() + + web_dev = driver.find_element(By.XPATH, '//span[contains(text(), "웹개발자")]') + web_dev.click() + + app_dev = driver.find_element(By.XPATH, '//span[contains(text(), "앱개발자")]') + app_dev.click() + + career_btn = driver.find_element(By.XPATH, '//p[contains(text(), "경력")]') + career_btn.click() + + newbie_label = driver.find_element(By.XPATH, '//label[contains(@for, "career1") and .//span[text()="신입"]]') + newbie_label.click() + + search_button = driver.find_element(By.ID, 'dev-btn-search') + search_button.click() + + time.sleep(4) + + try: + companies = wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, 'td.tplCo'))) + contents = wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, 'td.tplTit strong a.link'))) + dates = wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, 'span.date.dotum'))) + urls = wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, 'td.tplTit strong a.link'))) + except: + return None + + data_list = [] + + for i in range(len(companies)): + company_name = companies[i].text.strip() + content = contents[i].get_attribute("title") + + if not content: + content = contents[i].text.strip() + + date_text = dates[i].text.strip() + date_match = re.search(r"~(\d{2}/\d{2})\((\w+)\)", date_text) + + if date_match: + month_day, day_of_week = date_match.groups() + current_year = datetime.now().year + date_text = f"{current_year}-{month_day}" + expiration_date = datetime.strptime(date_text, "%Y-%m/%d") + elif "오늘마감" in date_text: + expiration_date = datetime.now() + elif "내일마감" in date_text: + expiration_date = datetime.now() + timedelta(days=1) + elif "모레마감" in date_text: + expiration_date = datetime.now() + timedelta(days=2) + elif "상시채용" in date_text: + expiration_date = datetime.max + else: + expiration_date = None + + if expiration_date: + expiration_date = expiration_date.strftime("%Y-%m-%d") + else: + expiration_date = "" + + url = urls[i].get_attribute("href") + + if content not in existing_contents: + data_list.append((company_name, content, expiration_date, url)) + + return data_list + +def main(): + driver = get_driver() + page_nuber = 1 + + db = get_database_connect() + cursor = db.cursor() + cursor.execute("SELECT title, expiration_date FROM job") + existing_contents = {row[0]: row[1] for row in cursor.fetchall()} + cursor.close() + db.close() + + while True: + job_data = crawling_job_data(driver, page_nuber, existing_contents.keys()) + if job_data is None: + break + + db = get_database_connect() + cursor = db.cursor() + insert_query = "INSERT INTO job (company_name, title, expiration_date, url) VALUES (%s, %s, %s, %s)" + cursor.executemany(insert_query, job_data) + db.commit() + + for title, expiration_date in existing_contents.items(): + if expiration_date == "9999-12-31 00:00:00.000000" and title not in [content[1] for content in job_data]: + cursor.execute("DELETE FROM job WHERE title = %s", (title,)) + db.commit() + + cursor.close() + db.close() + + page_nuber += 1 + + driver.quit() + +if __name__ == "__main__": + main() diff --git a/crawling_python/crawling_saramin.py b/crawling_python/crawling_saramin.py new file mode 100644 index 00000000..5e0e29d3 --- /dev/null +++ b/crawling_python/crawling_saramin.py @@ -0,0 +1,89 @@ +from global_utils import * +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from datetime import datetime, timedelta +from dotenv import load_dotenv +import re + +load_dotenv() + + +def crawling_job_data(driver, page_number, existing_contents): + url = f'https://www.saramin.co.kr/zf_user/jobs/public/list?exp_cd=1&company_cd=0%2C1%2C2%2C3%2C4%2C5%2C6%2C7%2C9%2C10&cat_kewd=84%2C86%2C87%2C92&panel_type=domestic&search_optional_item=y&search_done=y&panel_count=y&preview=y&page={page_number}&isAjaxRequest=y' + driver.get(url) + + wait = WebDriverWait(driver, 10) + + try: + companies = wait.until(EC.presence_of_all_elements_located( + (By.CSS_SELECTOR, '.list_item .col.company_nm a.str_tit, .list_item .col.company_nm span.str_tit'))) + contents = wait.until(EC.presence_of_all_elements_located( + (By.CSS_SELECTOR, '.list_body .col.notification_info .job_tit .str_tit'))) + urls = wait.until(EC.presence_of_all_elements_located( + (By.CSS_SELECTOR, '.list_body .col.notification_info .job_tit a.str_tit'))) + dates = wait.until(EC.presence_of_all_elements_located( + (By.CSS_SELECTOR, '.list_body .col.support_info .support_detail .date'))) + except: + return None + + data_list = [] + + for i in range(len(companies)): + company_name = companies[i].text + content = contents[i].text + url = urls[i].get_attribute('href') + date_text = dates[i].text + + match_d = re.search(r"D-(\d+)", date_text) + + match_date = re.search(r"~(\d+\.\d+)\((\w+)\)", date_text) + + if match_d: + days_to_add = int(match_d.group(1)) + current_date = datetime.now() + calculated_date = current_date + timedelta(days=days_to_add) + expiration_date = calculated_date.strftime("%Y-%m-%d") + elif match_date: + month_day, day_of_week = match_date.groups() + current_year = datetime.now().year + date_text = f"{current_year}-{month_day}" + expiration_date = datetime.strptime(date_text, "%Y-%m.%d").strftime("%Y-%m-%d") + + if content not in existing_contents: + data_list.append((company_name, content, url, expiration_date)) + + return data_list + + +def main(): + driver = get_driver() + page_number = 1 + + db = get_database_connect() + cursor = db.cursor() + cursor.execute("SELECT DISTINCT title FROM job") + existing_contents = {row[0] for row in cursor.fetchall()} + cursor.close() + db.close() + + while True: + job_data = crawling_job_data(driver, page_number, existing_contents) + if job_data is None: + break + + db = get_database_connect() + cursor = db.cursor() + insert_query = "INSERT INTO job (company_name, title, url, expiration_date) VALUES (%s, %s, %s, %s)" + cursor.executemany(insert_query, job_data) + db.commit() + cursor.close() + db.close() + + page_number += 1 + + driver.quit() + + +if __name__ == "__main__": + main() diff --git a/crawling_python/global_utils.py b/crawling_python/global_utils.py new file mode 100644 index 00000000..2feb4eb1 --- /dev/null +++ b/crawling_python/global_utils.py @@ -0,0 +1,34 @@ +import os +from selenium import webdriver +from mysql.connector import connect + + +def get_database_connect(): + return connect( + host="mysql", + port=3306, + user=os.getenv('DB_USERNAME'), + password=os.getenv('DB_USER_PASSWORD'), + database=os.getenv('DB_DATABASE') + ) + + +def get_driver(): + + path = '/usr/bin/chromedriver' + + chrome_options = webdriver.ChromeOptions() + chrome_options.add_argument("--headless") + chrome_options.add_argument("--no-sandbox") + chrome_options.add_argument("--disable-dev-shm-usage") + chrome_options.add_argument("--remote-debugging-port=9222") + chrome_options.add_argument("--disable-gpu") + chrome_options.add_argument("--disable-extensions") + chrome_options.add_argument("--log-level=DEBUG") + chrome_options.add_argument( + "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.92 Safari/537.36") + + service = webdriver.ChromeService(executable_path=path) + driver = webdriver.Chrome(options=chrome_options, service=service) + + return driver \ No newline at end of file diff --git a/crawling_python/requirements.txt b/crawling_python/requirements.txt new file mode 100644 index 00000000..c7ba8c8e --- /dev/null +++ b/crawling_python/requirements.txt @@ -0,0 +1,18 @@ +attrs==23.1.0 +certifi==2023.7.22 +h11==0.14.0 +idna==3.4 +mysql-connector-python==8.1.0 +outcome==1.2.0 +protobuf==4.21.12 +PySocks==1.7.1 +python-dotenv==1.0.0 +pytz==2023.3.post1 +schedule==1.2.0 +selenium==4.13.0 +sniffio==1.3.0 +sortedcontainers==2.4.0 +trio==0.22.2 +trio-websocket==0.11.1 +urllib3==2.0.5 +wsproto==1.2.0 diff --git a/crawling_python/scheduler.py b/crawling_python/scheduler.py new file mode 100644 index 00000000..6e00c893 --- /dev/null +++ b/crawling_python/scheduler.py @@ -0,0 +1,35 @@ +import pytz +import schedule +import subprocess + +from crawling_python.global_utils import get_database_connect + +kst = pytz.timezone('Asia/Seoul') + +def delete_expired_data(today): + kst = pytz.timezone('Asia/Seoul') + connect = get_database_connect() + cursor = connect.cursor() + + query = "DELETE FROM job WHERE expiration_date < %s" + cursor.execute(query, (today,)) + + connect.commit() + cursor.close() + connect.close() + +def crawl_jobkorea(): + subprocess.run(["python", "crawling_jobkorea.py"]) + + +def crawl_saramin(): + subprocess.run(["python", "crawling_saramin.py"]) + + +schedule.every(3).days.at("00:00").do(crawl_saramin) +schedule.every(3).days.at("00:03").do(crawl_jobkorea) +schedule.every().day.at("00:05").do(delete_expired_data) + +if __name__ == "__main__": + while True: + schedule.run_pending() diff --git a/frontend/src/app/(search)/search-user/[userName]/page.tsx b/frontend/src/app/(search)/search-user/[userName]/page.tsx index 9059cf6b..119bf57d 100644 --- a/frontend/src/app/(search)/search-user/[userName]/page.tsx +++ b/frontend/src/app/(search)/search-user/[userName]/page.tsx @@ -42,8 +42,11 @@ export default function SearchUserPage({ params }: ParamsType) { } async function getData() { + const queryString = new URLSearchParams({ + nickname: params.userName, + }).toString() const res = await fetch( - `${process.env.NEXT_PUBLIC_BASE_URL}/members?nickname=${params.userName}`, + `${process.env.NEXT_PUBLIC_BASE_URL}/members?${queryString}`, { headers: { 'Content-Type': 'application/json', @@ -54,17 +57,17 @@ export default function SearchUserPage({ params }: ParamsType) { const resData = await res.json() - if (resData.data.length === 0) { - setData([{ nickname: '검색 결과가 없습니다.', email: '' }]) - } else { - setData(resData.data) - } - if (!res.ok) { alert('검색 결과가 없습니다.') router.push('/') throw new Error('검색 결과가 없습니다.') } + + if (resData.data.length === 0) { + setData([{ nickname: '검색 결과가 없습니다.', email: '' }]) + } else { + setData(resData.data) + } } useEffect(() => { diff --git a/frontend/src/app/(user)/login/page.tsx b/frontend/src/app/(user)/login/page.tsx index 09c420d0..c33a49fc 100644 --- a/frontend/src/app/(user)/login/page.tsx +++ b/frontend/src/app/(user)/login/page.tsx @@ -11,7 +11,7 @@ import * as yup from 'yup' import Image from 'next/image' import Email from '../../../../public/images/svg/email.svg' -import { autoLoginState } from '../../../utils/atoms' +import { autoLoginState, usernameState } from '../../../utils/atoms' type DataObject = { email: string @@ -40,6 +40,7 @@ export default function Login() { const persistToken = typeof window !== 'undefined' ? localStorage.getItem('persistToken') : null const [autoLogin, setAutoLogin] = useRecoilState(autoLoginState) + const [, setUsername] = useRecoilState(usernameState) const [errorMessage, setErrorMessage] = useState('') const handleCheckboxChange = (event: React.ChangeEvent) => { @@ -92,6 +93,25 @@ export default function Login() { } else { sessionStorage.setItem('accessToken', resData.data.accessToken) } + + const myInfo = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/members/mypage`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${resData.data.accessToken}`, + }, + }, + ) + + const myInfoData = await myInfo.json() + + if (!myInfo.ok) { + throw new Error(myInfoData.message) + } + + setUsername(myInfoData.data.nickname) + router.push('/') } diff --git a/frontend/src/app/(user)/profile/[userName]/page.tsx b/frontend/src/app/(user)/profile/[userName]/page.tsx new file mode 100644 index 00000000..b05d8b20 --- /dev/null +++ b/frontend/src/app/(user)/profile/[userName]/page.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import Image from 'next/image' + +import WritedPost from '../../../../components/user/WritedPost' +import myProfile from '../../../../../public/images/png/myProfile.png' +import WriteIcon from '../../../../../public/images/svg/pencil-square.svg' +import FollowListModal from '../../../../components/user/FollowListModal' + +type GetProjectInfoResponse = { + id: number + content: string + projectName: string +} + +type Data = { + followerCount: number + followingCount: number + introduction: string + nickname: string + getProjectInfoResponseList: GetProjectInfoResponse[] +} + +type Response = { + data?: Data + message: string + code: string +} + +export default function ProfilePage({ + params, +}: { + params: { userName: string } +}) { + const [introduction, setIntroduction] = useState('') + const [followerCount, setFollowerCount] = useState(0) + const [followingCount, setFollowingCount] = useState(0) + const [getProjectInfoResponseList, setGetProjectInfoResponseList] = useState< + GetProjectInfoResponse[] + >([]) + const [isOpenModal, setOpenModal] = useState(false) + const [modeNumber, setModeNumber] = useState(0) + const [projectId, setProjectId] = useState(null) + + const router = useRouter() + + const onClickToggleModal = useCallback(() => { + setOpenModal(!isOpenModal) + }, [isOpenModal]) + + const onClickFollower = () => { + setModeNumber(0) + setProjectId(null) + onClickToggleModal() + } + + const onClickFollowing = () => { + setModeNumber(1) + setProjectId(null) + onClickToggleModal() + } + + const onClickLike = (project_id: number) => { + setModeNumber(2) + setProjectId(project_id) + onClickToggleModal() + } + + function toWrite() { + router.push('/write') + } + + async function getMyData() { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/members/mypage/${params.userName}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + const resData: Response = await res.json() + + if (!res.ok) { + throw new Error(resData.message) + } + + if (resData.data !== undefined) { + setIntroduction(resData.data.introduction) + setFollowerCount(resData.data.followerCount) + setFollowingCount(resData.data.followingCount) + setGetProjectInfoResponseList(resData.data.getProjectInfoResponseList) + } + } + + useEffect(() => { + getMyData() + }, []) + + return ( +
+
+ +
+
+ myProfile +
+
+
+ {params.userName} +
+ +
+ + + + {isOpenModal ? ( + + ) : null} +
+
+
+ {introduction} +
+
+
+
+
+ 나의 작성 포스트 +
+
+ {getProjectInfoResponseList.map((project) => ( + onClickLike(project.id)} + /> + ))} +
+
+
+
+ ) +} diff --git a/frontend/src/app/(user)/profile/page.tsx b/frontend/src/app/(user)/profile/page.tsx index e69de29b..c4608db4 100644 --- a/frontend/src/app/(user)/profile/page.tsx +++ b/frontend/src/app/(user)/profile/page.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +type GetProjectInfoResponse = { + id: number + content: string + projectName: string +} + +type Data = { + followerCount: number + followingCount: number + introduction: string + nickname: string + getProjectInfoResponseList: GetProjectInfoResponse[] +} + +type Response = { + data?: Data + message: string + code: string +} + +export default function MyPage() { + const accessToken = + typeof window !== 'undefined' ? sessionStorage.getItem('accessToken') : null + const persistToken = + typeof window !== 'undefined' ? localStorage.getItem('persistToken') : null + + const router = useRouter() + + async function getMyData() { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/members/mypage`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken || persistToken}`, + }, + }, + ) + + const resData: Response = await res.json() + + if (!res.ok) { + throw new Error(resData.message) + } + + if (resData.data !== undefined) { + router.push(`/profile/${resData.data.nickname}`) + } + } + + useEffect(() => { + getMyData() + }, []) + + return
+} diff --git a/frontend/src/app/(user)/registration/page.tsx b/frontend/src/app/(user)/registration/page.tsx index 77b45277..9fbc4718 100644 --- a/frontend/src/app/(user)/registration/page.tsx +++ b/frontend/src/app/(user)/registration/page.tsx @@ -6,12 +6,12 @@ import { yupResolver } from '@hookform/resolvers/yup' import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { useRouter } from 'next/navigation' -import { useRecoilValue } from 'recoil' +import { useRecoilState, useRecoilValue } from 'recoil' import * as yup from 'yup' import Image from 'next/image' import Email from '../../../../public/images/svg/email.svg' -import { autoLoginState } from '../../../utils/atoms' +import { autoLoginState, usernameState } from '../../../utils/atoms' type DataObject = { email: string @@ -54,6 +54,7 @@ export default function Registration() { const persistToken = typeof window !== 'undefined' ? localStorage.getItem('persistToken') : null const autoLogin = useRecoilValue(autoLoginState) + const [, setUsername] = useRecoilState(usernameState) const [errorMessage, setErrorMessage] = useState('') const { register, @@ -72,7 +73,7 @@ export default function Registration() { } const onSubmit: SubmitHandler = async (data: DataObject) => { - const res1 = await fetch( + const registeration = await fetch( `${process.env.NEXT_PUBLIC_BASE_URL}/auth/signup`, { method: 'POST', @@ -88,16 +89,16 @@ export default function Registration() { }, ) - const res1Data = await res1.json() + const registerData = await registeration.json() - if (!res1.ok) { - setErrorMessage(res1Data.message) - throw new Error(res1Data.message) + if (!registeration.ok) { + setErrorMessage(registerData.message) + throw new Error(registerData.message) } setErrorMessage('') - const res2 = await fetch( + const login = await fetch( `${process.env.NEXT_PUBLIC_BASE_URL}/auth/signin`, { method: 'POST', @@ -111,17 +112,36 @@ export default function Registration() { }, ) - if (!res2.ok) { + if (!login.ok) { throw new Error('회원가입 시 자동 로그인 실패') } - const res2Data = await res2.json() + const loginData = await login.json() if (autoLogin) { - localStorage.setItem('persistToken', res2Data.data.accessToken) + localStorage.setItem('persistToken', loginData.data.accessToken) } else { - sessionStorage.setItem('accessToken', res2Data.data.accessToken) + sessionStorage.setItem('accessToken', loginData.data.accessToken) } + + const myInfo = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/members/mypage`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${loginData.data.accessToken}`, + }, + }, + ) + + const myInfoData = await myInfo.json() + + if (!myInfo.ok) { + throw new Error(myInfoData.message) + } + + setUsername(myInfoData.data.nickname) + router.push('/') } diff --git a/frontend/src/components/general/NavBar.tsx b/frontend/src/components/general/NavBar.tsx index 08261038..9b60efc8 100644 --- a/frontend/src/components/general/NavBar.tsx +++ b/frontend/src/components/general/NavBar.tsx @@ -8,7 +8,7 @@ import Image from 'next/image' import WriteIcon from '../../../public/images/svg/pencil-square.svg' import ProfileIcon from '../../../public/images/svg/profileIcon.svg' import SearchIcon from '../../../public/images/png/searchIcon.png' -import { searchTextState } from '../../utils/atoms' +import { searchTextState, usernameState } from '../../utils/atoms' export default function NavBar({ children }: { children: React.ReactNode }) { const accessToken = @@ -16,6 +16,7 @@ export default function NavBar({ children }: { children: React.ReactNode }) { const persistToken = typeof window !== 'undefined' ? localStorage.getItem('persistToken') : null const [searchText, SetSearchText] = useRecoilState(searchTextState) + const [username, setUsername] = useRecoilState(usernameState) const [btnText, setBtnText] = useState('로그인') const router = useRouter() @@ -26,6 +27,7 @@ export default function NavBar({ children }: { children: React.ReactNode }) { } function signOut() { + setUsername('') if (accessToken) { sessionStorage.removeItem('accessToken') } else { @@ -37,8 +39,8 @@ export default function NavBar({ children }: { children: React.ReactNode }) { if (searchText === '' || searchText === '@') { router.push('/') } else if (searchText.charAt(0) === '@') { - const username = searchText.substring(1) - router.push(`/search-user/${username}`) + const searchUserName = searchText.substring(1) + router.push(`/search-user/${searchUserName}`) } else { router.push(`/search-post/${searchText}`) } @@ -127,12 +129,12 @@ export default function NavBar({ children }: { children: React.ReactNode }) { WriteIcon 프로젝트 공유 - {/* 마이페이지 아이콘 */}
+ } + if (modenumber === 1) { + return
의 팔로잉
+ } + return
의 포스트에 반응한 사람
+} + +function modeImage(modenumber: number) { + if (modenumber === 0) { + return ( + followerIcon + ) + } + if (modenumber === 1) { + return followerIcon + } + return LikeIcon +} + +type ModeModalProps = { + onClickToggleModal: () => void + modeNumber: number + userName: string + projectId: number | null +} + +export default function FollowListModal({ + onClickToggleModal, + modeNumber, + userName, + projectId, +}: ModeModalProps) { + const accessToken = + typeof window !== 'undefined' ? sessionStorage.getItem('accessToken') : null + const persistToken = + typeof window !== 'undefined' ? localStorage.getItem('persistToken') : null + const [userList, setUserList] = useState<{ id: number; nickname: string }[]>( + [], + ) + + async function modeList(modenumber: number) { + if (modenumber === 0) { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/follow/following`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken || persistToken}`, + }, + }, + ) + + const resData = await res.json() + + if (!res.ok) { + throw new Error(resData.message) + } + + setUserList(resData.data) + } else if (modenumber === 1) { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/follow/follower`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken || persistToken}`, + }, + }, + ) + + const resData = await res.json() + + if (!res.ok) { + throw new Error(resData.message) + } + + setUserList(resData.data) + } else { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/likes/${projectId}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken || persistToken}`, + }, + }, + ) + + const resData = await res.json() + + if (!res.ok) { + throw new Error(resData.message) + } + + setUserList(resData.data) + } + } + + useEffect(() => { + modeList(modeNumber) + }, [modeNumber]) + + return ( +
+
+
+
{userName}
+ {modeText(modeNumber)} +
+ + {/* 구분선 */} +
+ + {userList.map((user) => ( +
+ {modeImage(modeNumber)} +
+ {user.nickname} +
+
+ 본인을 소개하는 한 마디 두 마디 세 마디 +
+
+ ))} +
+ + {/* 모달 배경 버튼 */} +
+ ) +} diff --git a/frontend/src/components/user/WritedPost.tsx b/frontend/src/components/user/WritedPost.tsx new file mode 100644 index 00000000..cb03011c --- /dev/null +++ b/frontend/src/components/user/WritedPost.tsx @@ -0,0 +1,50 @@ +import Image from 'next/image' +import Link from 'next/link' + +import Like from '../../../public/images/svg/Like.svg' + +type GetProjectInfoResponse = { + id: number + content: string + projectName: string +} + +type WritedPostProps = { + getProjectInfoResponse: GetProjectInfoResponse + onClickLike: () => void +} + +export default function WritedPost({ + getProjectInfoResponse, + onClickLike, +}: WritedPostProps) { + const content = getProjectInfoResponse.content.replace(/<[^>]*>?/g, '') + return ( +
+ {/* 좋아요 */} + + + {/* 제목 */} +
+ {getProjectInfoResponse.projectName} +
+ {/* 본문 미리보기 */} +
+ {content} +
+
+ +
+ ) +} diff --git a/frontend/src/utils/atoms.ts b/frontend/src/utils/atoms.ts index 44c9225b..24ae1d6a 100644 --- a/frontend/src/utils/atoms.ts +++ b/frontend/src/utils/atoms.ts @@ -43,32 +43,6 @@ const refreshState = atom({ default: false, }) -const searchTextState = atom({ - key: 'searchTextState', - effects_UNSTABLE: [persistAtom], -}) - -const tldrState = atom({ - key: 'tldrState', - default: '', - effects_UNSTABLE: [persistAtom], -}) - -const thumbnailUrlState = atom({ - key: 'thumbnailUrlState', - default: null, -}) - -const selectedStackState = atom({ - key: 'selectedStackState', - default: [], -}) - -const contentsState = atom({ - key: 'contentsState', - default: '', -}) - const autoLoginState = atom({ key: 'autoLoginState', default: false, @@ -115,17 +89,16 @@ const nicknameState = atom({ default: '', }) -const projectIdState = atom({ - key: 'projectIdState', - default: 0, - effects_UNSTABLE: [persistAtom], -}) - const searchTextState = atom({ key: 'searchTextState', default: '', }) +const usernameState = atom({ + key: 'usernameState', + default: '', +}) + export { titleState, tldrState, @@ -138,11 +111,11 @@ export { statusOpenState, modalContentState, nicknameState, - projectDataState, thumbnailUrlState, selectedStackState, contentsState, autoLoginState, projectIdState, searchTextState, + usernameState, }