diff --git a/linkareer-data-mapping-guide.md b/linkareer-data-mapping-guide.md index 8d4315e6..28833e22 100644 --- a/linkareer-data-mapping-guide.md +++ b/linkareer-data-mapping-guide.md @@ -10,7 +10,7 @@ | 컬럼명 | 설명 | |--------|------| -| `/상세링크` | 링커리어 상세 링크 | +| `상세링크` 또는 `/상세링크` | 링커리어 상세 링크 | | `활동유형` | 대외활동 / 공모전/해커톤 / 교육 / 강연/세미나 | | `출처` | 링커리어 | | `제목` | 활동 제목 | @@ -117,7 +117,7 @@ - `26.3 ~ 26.10`은 `2026-03-01 ~ 2026-10-01`처럼 월 첫날로 변환한다. - `2026.05.17 ~ 2026.08.07`은 해당 날짜 그대로 변환한다. -- `-` 또는 비어 있으면 `start_date = recruitment_start_date`, `end_date = null`, `duration = -1`로 저장한다. +- `-` 또는 비어 있으면 `start_date = recruitment_start_date`, `end_date = null`, `duration = 0`으로 저장한다. - 기간 계산이 가능하면 `duration`은 시작일과 종료일 사이의 일수로 저장한다. ### status @@ -130,10 +130,11 @@ ### URL, 이미지, 본문 - `홈페이지` -> `activity_homepage_url` -- `/상세링크` -> `activity_application_url`. 상대경로이면 링커리어 도메인을 붙여 절대 URL로 만든다. +- `상세링크` 또는 `/상세링크` -> `activity_application_url`. 상대경로이면 링커리어 도메인을 붙여 절대 URL로 만든다. - `썸네일이미지` -> `activity_thumbnail_url` - `상세내용` -> `description` -- `description`은 2000자 제한이다. 2000자를 초과하면 핵심 모집 정보가 남도록 1997자 이내로 줄이고 `...`을 붙인다. +- URL 필드는 255자 제한이다. `activity_homepage_url`이 255자를 초과하면 잘라서 깨진 URL을 저장하지 말고 `null`로 둔다. +- `description`은 2000자 제한이다. 이모지 등으로 서버 검증 길이가 달라질 수 있으므로 핵심 모집 정보가 남도록 1900자 이내로 줄이고 `...`을 붙인다. - `benefit`은 본문에서 혜택이 명확히 분리될 때만 추출하고, 불명확하면 빈 문자열로 둔다. --- diff --git a/src/main/kotlin/picklab/backend/archive/domain/repository/ArchiveRepository.kt b/src/main/kotlin/picklab/backend/archive/domain/repository/ArchiveRepository.kt index 0f854a3c..5f04a1dc 100644 --- a/src/main/kotlin/picklab/backend/archive/domain/repository/ArchiveRepository.kt +++ b/src/main/kotlin/picklab/backend/archive/domain/repository/ArchiveRepository.kt @@ -17,6 +17,11 @@ interface ArchiveRepository : JpaRepository { member: Member, ): Archive? + fun existsByActivityIdAndMemberIdAndDeletedAtIsNull( + activityId: Long, + memberId: Long, + ): Boolean + @Query("SELECT a FROM Archive a JOIN FETCH a.activity WHERE a.member = :member AND a.activityProgressStatus = :status") fun findByMemberAndProgressStatus( @Param("member") member: Member, diff --git a/src/main/kotlin/picklab/backend/archive/domain/service/ArchiveService.kt b/src/main/kotlin/picklab/backend/archive/domain/service/ArchiveService.kt index 94548b07..ebff94aa 100644 --- a/src/main/kotlin/picklab/backend/archive/domain/service/ArchiveService.kt +++ b/src/main/kotlin/picklab/backend/archive/domain/service/ArchiveService.kt @@ -25,6 +25,11 @@ class ArchiveService( archiveRepository .findByIdAndMember(archiveId, member) ?: throw BusinessException(ErrorCode.NOT_FOUND_ARCHIVE) + fun existsActiveByActivityIdAndMemberId( + activityId: Long, + memberId: Long, + ): Boolean = archiveRepository.existsByActivityIdAndMemberIdAndDeletedAtIsNull(activityId, memberId) + fun findCompletedArchives( member: Member, activityType: ActivityType?, diff --git a/src/main/kotlin/picklab/backend/common/model/ErrorCode.kt b/src/main/kotlin/picklab/backend/common/model/ErrorCode.kt index f851dc8d..b5061804 100644 --- a/src/main/kotlin/picklab/backend/common/model/ErrorCode.kt +++ b/src/main/kotlin/picklab/backend/common/model/ErrorCode.kt @@ -50,6 +50,9 @@ enum class ErrorCode( * 활동 참여 도메인 관련 */ NOT_FOUND_ACTIVITY_PARTICIPATION(HttpStatus.NOT_FOUND, "해당 활동에 대한 참여 이력이 존재하지 않습니다."), + ALREADY_EXISTS_ACTIVITY_PARTICIPATION(HttpStatus.BAD_REQUEST, "이미 해당 활동을 지원 완료로 표시했습니다."), + CANNOT_CANCEL_ACTIVITY_PARTICIPATION(HttpStatus.BAD_REQUEST, "리뷰 또는 아카이브가 있는 활동 참여는 취소할 수 없습니다."), + CANNOT_UPDATE_ACTIVITY_PROGRESS_STATUS(HttpStatus.BAD_REQUEST, "최종 합격 상태에서만 수료 여부를 수정할 수 있습니다."), /** * 아카이브 도메인 관련 diff --git a/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt b/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt index 918eed7b..f13d3fd5 100644 --- a/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt +++ b/src/main/kotlin/picklab/backend/common/model/SuccessCode.kt @@ -36,6 +36,13 @@ enum class SuccessCode( GET_BOOKMARKS(HttpStatus.OK, "북마크 목록 조회에 성공했습니다."), INCREASE_VIEW_COUNT(HttpStatus.OK, "조회 수 증가에 성공했습니다."), + // Activity Participation 관련 + CREATE_ACTIVITY_PARTICIPATION(HttpStatus.CREATED, "활동을 지원 완료로 표시했습니다."), + DELETE_ACTIVITY_PARTICIPATION(HttpStatus.OK, "활동 지원 완료 표시를 취소했습니다."), + UPDATE_ACTIVITY_PARTICIPATION(HttpStatus.OK, "활동 결과를 수정했습니다."), + GET_ACTIVITY_PARTICIPATIONS(HttpStatus.OK, "활동 결과 목록 조회에 성공했습니다."), + GET_ACTIVITY_PARTICIPATION_SUMMARY(HttpStatus.OK, "활동 결과 현황 조회에 성공했습니다."), + // Archive 관련 CREATE_ARCHIVE_SUCCESS(HttpStatus.OK, "아카이브 생성에 성공했습니다."), UPDATE_ARCHIVE_SUCCESS(HttpStatus.OK, "아카이브 상태 수정에 성공했습니다."), diff --git a/src/main/kotlin/picklab/backend/participation/application/ActivityParticipationUseCase.kt b/src/main/kotlin/picklab/backend/participation/application/ActivityParticipationUseCase.kt index 305a64a8..efc145d8 100644 --- a/src/main/kotlin/picklab/backend/participation/application/ActivityParticipationUseCase.kt +++ b/src/main/kotlin/picklab/backend/participation/application/ActivityParticipationUseCase.kt @@ -1,6 +1,101 @@ package picklab.backend.participation.application +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import picklab.backend.activity.domain.service.ActivityService +import picklab.backend.archive.domain.service.ArchiveService +import picklab.backend.common.model.BusinessException +import picklab.backend.common.model.ErrorCode +import picklab.backend.common.model.PageResponse +import picklab.backend.common.model.toPageResponse +import picklab.backend.member.domain.MemberService +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.domain.enums.ProgressStatus +import picklab.backend.participation.domain.service.ActivityParticipationService +import picklab.backend.participation.entrypoint.response.ActivityParticipationResultResponse +import picklab.backend.participation.entrypoint.response.ActivityParticipationSummaryResponse +import picklab.backend.review.domain.service.ReviewService @Component -class ActivityParticipationUseCase +class ActivityParticipationUseCase( + private val memberService: MemberService, + private val activityService: ActivityService, + private val participationService: ActivityParticipationService, + private val reviewService: ReviewService, + private val archiveService: ArchiveService, +) { + @Transactional + fun createAppliedParticipation( + memberId: Long, + activityId: Long, + ) { + val member = memberService.findActiveMember(memberId) + val activity = activityService.mustFindById(activityId) + + participationService.createAppliedParticipation(member, activity) + } + + @Transactional + fun cancelAppliedParticipation( + memberId: Long, + activityId: Long, + ) { + val participation = participationService.mustFindByMemberIdAndActivityId(memberId, activityId) + val hasReview = reviewService.existsActiveByActivityIdAndMemberId(activityId, memberId) + val hasArchive = archiveService.existsActiveByActivityIdAndMemberId(activityId, memberId) + if (hasReview || hasArchive) { + throw BusinessException(ErrorCode.CANNOT_CANCEL_ACTIVITY_PARTICIPATION) + } + + participationService.delete(participation) + } + + @Transactional + fun updateApplicationStatus( + memberId: Long, + participationId: Long, + applicationStatus: ApplicationStatus, + ) { + val participation = participationService.mustFindByIdAndMemberId(participationId, memberId) + participationService.updateApplicationStatus(participation, applicationStatus) + } + + @Transactional + fun updateProgressStatus( + memberId: Long, + participationId: Long, + progressStatus: ProgressStatus, + ) { + val participation = participationService.mustFindByIdAndMemberId(participationId, memberId) + participationService.updateProgressStatus(participation, progressStatus) + } + + @Transactional(readOnly = true) + fun getResults( + memberId: Long, + applicationStatuses: List?, + page: Int, + size: Int, + ): PageResponse { + memberService.findActiveMember(memberId) + val pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()) + + return participationService + .findResults(memberId, applicationStatuses, pageable) + .toPageResponse { ActivityParticipationResultResponse.from(it) } + } + + @Transactional(readOnly = true) + fun getSummary(memberId: Long): ActivityParticipationSummaryResponse { + memberService.findActiveMember(memberId) + + return ActivityParticipationSummaryResponse( + appliedCount = participationService.countAll(memberId), + acceptedCount = participationService.countByApplicationStatus(memberId, ApplicationStatus.ACCEPTED), + rejectedCount = participationService.countByApplicationStatus(memberId, ApplicationStatus.REJECTED), + completedCount = participationService.countByProgressStatus(memberId, ProgressStatus.COMPLETED), + ) + } +} diff --git a/src/main/kotlin/picklab/backend/participation/domain/entity/ActivityParticipation.kt b/src/main/kotlin/picklab/backend/participation/domain/entity/ActivityParticipation.kt index 5528299d..3c3b5d12 100644 --- a/src/main/kotlin/picklab/backend/participation/domain/entity/ActivityParticipation.kt +++ b/src/main/kotlin/picklab/backend/participation/domain/entity/ActivityParticipation.kt @@ -28,7 +28,7 @@ class ActivityParticipation( var applicationStatus: ApplicationStatus, @Column(name = "progress_status", length = 50, nullable = false) @Enumerated(EnumType.STRING) - @Comment("진행 상태 (진행 중 / 수료 완료 / 중도 포기)") + @Comment("진행 상태 (미선택 / 진행 중 / 수료 완료 / 중도 포기)") var progressStatus: ProgressStatus, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) @@ -37,6 +37,17 @@ class ActivityParticipation( @JoinColumn(name = "activity_id", nullable = false) val activity: Activity, ) : SoftDeleteEntity() { + fun updateApplicationStatus(applicationStatus: ApplicationStatus) { + this.applicationStatus = applicationStatus + if (applicationStatus != ApplicationStatus.ACCEPTED) { + this.progressStatus = ProgressStatus.NOT_SELECTED + } + } + + fun updateProgressStatus(progressStatus: ProgressStatus) { + this.progressStatus = progressStatus + } + fun canWriteReview(): Boolean = progressStatus == ProgressStatus.COMPLETED || progressStatus == ProgressStatus.DROPPED diff --git a/src/main/kotlin/picklab/backend/participation/domain/enums/ProgressStatus.kt b/src/main/kotlin/picklab/backend/participation/domain/enums/ProgressStatus.kt index 6469c369..7f03816e 100644 --- a/src/main/kotlin/picklab/backend/participation/domain/enums/ProgressStatus.kt +++ b/src/main/kotlin/picklab/backend/participation/domain/enums/ProgressStatus.kt @@ -3,6 +3,7 @@ package picklab.backend.participation.domain.enums enum class ProgressStatus( val label: String, ) { + NOT_SELECTED("미선택"), IN_PROGRESSING("진행 중"), COMPLETED("수료 완료"), DROPPED("중도 포기"), diff --git a/src/main/kotlin/picklab/backend/participation/domain/repository/ActivityParticipationRepository.kt b/src/main/kotlin/picklab/backend/participation/domain/repository/ActivityParticipationRepository.kt index f3cdaa08..935110b9 100644 --- a/src/main/kotlin/picklab/backend/participation/domain/repository/ActivityParticipationRepository.kt +++ b/src/main/kotlin/picklab/backend/participation/domain/repository/ActivityParticipationRepository.kt @@ -1,11 +1,38 @@ package picklab.backend.participation.domain.repository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import picklab.backend.participation.domain.entity.ActivityParticipation +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.domain.enums.ProgressStatus interface ActivityParticipationRepository : JpaRepository { fun findByMemberIdAndActivityId( memberId: Long, activityId: Long, ): ActivityParticipation? + + fun findAllByMemberId( + memberId: Long, + pageable: Pageable, + ): Page + + fun findAllByMemberIdAndApplicationStatusIn( + memberId: Long, + applicationStatuses: List, + pageable: Pageable, + ): Page + + fun countByMemberId(memberId: Long): Long + + fun countByMemberIdAndApplicationStatus( + memberId: Long, + applicationStatus: ApplicationStatus, + ): Long + + fun countByMemberIdAndProgressStatus( + memberId: Long, + progressStatus: ProgressStatus, + ): Long } diff --git a/src/main/kotlin/picklab/backend/participation/domain/service/ActivityParticipationService.kt b/src/main/kotlin/picklab/backend/participation/domain/service/ActivityParticipationService.kt index 35fb8b69..38aea058 100644 --- a/src/main/kotlin/picklab/backend/participation/domain/service/ActivityParticipationService.kt +++ b/src/main/kotlin/picklab/backend/participation/domain/service/ActivityParticipationService.kt @@ -1,15 +1,48 @@ package picklab.backend.participation.domain.service +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service +import picklab.backend.activity.domain.entity.Activity import picklab.backend.common.model.BusinessException import picklab.backend.common.model.ErrorCode +import picklab.backend.member.domain.entity.Member import picklab.backend.participation.domain.entity.ActivityParticipation +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.domain.enums.ProgressStatus import picklab.backend.participation.domain.repository.ActivityParticipationRepository @Service class ActivityParticipationService( private val participationRepository: ActivityParticipationRepository, ) { + fun createAppliedParticipation( + member: Member, + activity: Activity, + ): ActivityParticipation { + if (participationRepository.findByMemberIdAndActivityId(member.id, activity.id) != null) { + throw BusinessException(ErrorCode.ALREADY_EXISTS_ACTIVITY_PARTICIPATION) + } + + return participationRepository.save( + ActivityParticipation( + applicationStatus = ApplicationStatus.APPLIED, + progressStatus = ProgressStatus.NOT_SELECTED, + member = member, + activity = activity, + ), + ) + } + + fun mustFindByIdAndMemberId( + id: Long, + memberId: Long, + ): ActivityParticipation = + participationRepository + .findById(id) + .filter { it.member.id == memberId } + .orElseThrow { BusinessException(ErrorCode.NOT_FOUND_ACTIVITY_PARTICIPATION) } + fun mustFindByMemberIdAndActivityId( memberId: Long, activityId: Long, @@ -17,6 +50,54 @@ class ActivityParticipationService( participationRepository.findByMemberIdAndActivityId(memberId, activityId) ?: throw BusinessException(ErrorCode.NOT_FOUND_ACTIVITY_PARTICIPATION) + fun findResults( + memberId: Long, + applicationStatuses: List?, + pageable: Pageable, + ): Page = + if (applicationStatuses.isNullOrEmpty()) { + participationRepository.findAllByMemberId(memberId, pageable) + } else { + participationRepository.findAllByMemberIdAndApplicationStatusIn(memberId, applicationStatuses, pageable) + } + + fun countAll(memberId: Long): Long = participationRepository.countByMemberId(memberId) + + fun countByApplicationStatus( + memberId: Long, + applicationStatus: ApplicationStatus, + ): Long = participationRepository.countByMemberIdAndApplicationStatus(memberId, applicationStatus) + + fun countByProgressStatus( + memberId: Long, + progressStatus: ProgressStatus, + ): Long = participationRepository.countByMemberIdAndProgressStatus(memberId, progressStatus) + + fun updateApplicationStatus( + participation: ActivityParticipation, + applicationStatus: ApplicationStatus, + ) { + participation.updateApplicationStatus(applicationStatus) + participationRepository.save(participation) + } + + fun updateProgressStatus( + participation: ActivityParticipation, + progressStatus: ProgressStatus, + ) { + if (participation.applicationStatus != ApplicationStatus.ACCEPTED) { + throw BusinessException(ErrorCode.CANNOT_UPDATE_ACTIVITY_PROGRESS_STATUS) + } + + participation.updateProgressStatus(progressStatus) + participationRepository.save(participation) + } + + fun delete(participation: ActivityParticipation) { + participation.delete() + participationRepository.save(participation) + } + fun validateCanWriteReview( memberId: Long, activityId: Long, diff --git a/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationApi.kt b/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationApi.kt index 35797581..0b9e9180 100644 --- a/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationApi.kt +++ b/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationApi.kt @@ -1,6 +1,74 @@ package picklab.backend.participation.entrypoint +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import picklab.backend.common.model.MemberPrincipal +import picklab.backend.common.model.PageResponse +import picklab.backend.common.model.ResponseWrapper +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.entrypoint.request.UpdateApplicationStatusRequest +import picklab.backend.participation.entrypoint.request.UpdateProgressStatusRequest +import picklab.backend.participation.entrypoint.response.ActivityParticipationResultResponse +import picklab.backend.participation.entrypoint.response.ActivityParticipationSummaryResponse @Tag(name = "활동 지원 API", description = "활동 지원 관련된 API") -interface ActivityParticipationApi +interface ActivityParticipationApi { + @Operation( + summary = "활동 지원 완료 표시", + description = "로그인한 사용자가 활동을 지원 완료로 표시합니다.", + responses = [ApiResponse(responseCode = "201", description = "활동 지원 완료 표시 성공")], + ) + fun create( + member: MemberPrincipal, + activityId: Long, + ): ResponseEntity> + + @Operation( + summary = "활동 지원 완료 표시 취소", + description = "로그인한 사용자의 활동 지원 완료 표시를 취소합니다.", + responses = [ApiResponse(responseCode = "200", description = "활동 지원 완료 표시 취소 성공")], + ) + fun cancel( + member: MemberPrincipal, + activityId: Long, + ): ResponseEntity> + + @Operation( + summary = "합격 여부 수정", + description = "활동 참여의 지원 상태를 수정합니다.", + ) + fun updateApplicationStatus( + member: MemberPrincipal, + participationId: Long, + request: UpdateApplicationStatusRequest, + ): ResponseEntity> + + @Operation( + summary = "수료 여부 수정", + description = "최종 합격한 활동 참여의 수료 상태를 수정합니다.", + ) + fun updateProgressStatus( + member: MemberPrincipal, + participationId: Long, + request: UpdateProgressStatusRequest, + ): ResponseEntity> + + @Operation( + summary = "활동 결과 목록 조회", + description = "로그인한 사용자가 지원 완료로 표시한 활동 결과 목록을 조회합니다.", + ) + fun getResults( + member: MemberPrincipal, + applicationStatus: List?, + page: Int, + size: Int, + ): ResponseEntity>> + + @Operation( + summary = "활동 결과 현황 조회", + description = "지원 완료, 최종 합격, 불합격, 수료 완료 개수를 조회합니다.", + ) + fun getSummary(member: MemberPrincipal): ResponseEntity> +} diff --git a/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationController.kt b/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationController.kt index 4a96d715..dab30c96 100644 --- a/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationController.kt +++ b/src/main/kotlin/picklab/backend/participation/entrypoint/ActivityParticipationController.kt @@ -1,6 +1,104 @@ package picklab.backend.participation.entrypoint +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import picklab.backend.common.model.MemberPrincipal +import picklab.backend.common.model.PageResponse +import picklab.backend.common.model.ResponseWrapper +import picklab.backend.common.model.SuccessCode +import picklab.backend.participation.application.ActivityParticipationUseCase +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.entrypoint.request.UpdateApplicationStatusRequest +import picklab.backend.participation.entrypoint.request.UpdateProgressStatusRequest +import picklab.backend.participation.entrypoint.response.ActivityParticipationResultResponse +import picklab.backend.participation.entrypoint.response.ActivityParticipationSummaryResponse @RestController -class ActivityParticipationController : ActivityParticipationApi +class ActivityParticipationController( + private val activityParticipationUseCase: ActivityParticipationUseCase, +) : ActivityParticipationApi { + @PostMapping("/v1/activities/{activityId}/participations") + override fun create( + @AuthenticationPrincipal member: MemberPrincipal, + @PathVariable activityId: Long, + ): ResponseEntity> { + activityParticipationUseCase.createAppliedParticipation(member.memberId, activityId) + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ResponseWrapper.success(SuccessCode.CREATE_ACTIVITY_PARTICIPATION)) + } + + @DeleteMapping("/v1/activities/{activityId}/participations") + override fun cancel( + @AuthenticationPrincipal member: MemberPrincipal, + @PathVariable activityId: Long, + ): ResponseEntity> { + activityParticipationUseCase.cancelAppliedParticipation(member.memberId, activityId) + return ResponseEntity.ok(ResponseWrapper.success(SuccessCode.DELETE_ACTIVITY_PARTICIPATION)) + } + + @PatchMapping("/v1/activity-participations/{participationId}/application-status") + override fun updateApplicationStatus( + @AuthenticationPrincipal member: MemberPrincipal, + @PathVariable participationId: Long, + @Valid @RequestBody request: UpdateApplicationStatusRequest, + ): ResponseEntity> { + activityParticipationUseCase.updateApplicationStatus( + memberId = member.memberId, + participationId = participationId, + applicationStatus = request.applicationStatus, + ) + return ResponseEntity.ok(ResponseWrapper.success(SuccessCode.UPDATE_ACTIVITY_PARTICIPATION)) + } + + @PatchMapping("/v1/activity-participations/{participationId}/progress-status") + override fun updateProgressStatus( + @AuthenticationPrincipal member: MemberPrincipal, + @PathVariable participationId: Long, + @Valid @RequestBody request: UpdateProgressStatusRequest, + ): ResponseEntity> { + activityParticipationUseCase.updateProgressStatus( + memberId = member.memberId, + participationId = participationId, + progressStatus = request.progressStatus, + ) + return ResponseEntity.ok(ResponseWrapper.success(SuccessCode.UPDATE_ACTIVITY_PARTICIPATION)) + } + + @GetMapping("/v1/activity-participations/results") + override fun getResults( + @AuthenticationPrincipal member: MemberPrincipal, + @RequestParam(required = false) applicationStatus: List?, + @RequestParam(defaultValue = "1") @Min(1) page: Int, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) size: Int, + ): ResponseEntity>> { + val response = + activityParticipationUseCase.getResults( + memberId = member.memberId, + applicationStatuses = applicationStatus, + page = page, + size = size, + ) + return ResponseEntity.ok(ResponseWrapper.success(SuccessCode.GET_ACTIVITY_PARTICIPATIONS, response)) + } + + @GetMapping("/v1/activity-participations/summary") + override fun getSummary( + @AuthenticationPrincipal member: MemberPrincipal, + ): ResponseEntity> { + val response = activityParticipationUseCase.getSummary(member.memberId) + return ResponseEntity.ok(ResponseWrapper.success(SuccessCode.GET_ACTIVITY_PARTICIPATION_SUMMARY, response)) + } +} diff --git a/src/main/kotlin/picklab/backend/participation/entrypoint/request/UpdateApplicationStatusRequest.kt b/src/main/kotlin/picklab/backend/participation/entrypoint/request/UpdateApplicationStatusRequest.kt new file mode 100644 index 00000000..01cb694a --- /dev/null +++ b/src/main/kotlin/picklab/backend/participation/entrypoint/request/UpdateApplicationStatusRequest.kt @@ -0,0 +1,9 @@ +package picklab.backend.participation.entrypoint.request + +import io.swagger.v3.oas.annotations.media.Schema +import picklab.backend.participation.domain.enums.ApplicationStatus + +data class UpdateApplicationStatusRequest( + @field:Schema(description = "지원 상태", example = "ACCEPTED") + val applicationStatus: ApplicationStatus, +) diff --git a/src/main/kotlin/picklab/backend/participation/entrypoint/request/UpdateProgressStatusRequest.kt b/src/main/kotlin/picklab/backend/participation/entrypoint/request/UpdateProgressStatusRequest.kt new file mode 100644 index 00000000..f0abdf3d --- /dev/null +++ b/src/main/kotlin/picklab/backend/participation/entrypoint/request/UpdateProgressStatusRequest.kt @@ -0,0 +1,9 @@ +package picklab.backend.participation.entrypoint.request + +import io.swagger.v3.oas.annotations.media.Schema +import picklab.backend.participation.domain.enums.ProgressStatus + +data class UpdateProgressStatusRequest( + @field:Schema(description = "진행 상태", example = "COMPLETED") + val progressStatus: ProgressStatus, +) diff --git a/src/main/kotlin/picklab/backend/participation/entrypoint/response/ActivityParticipationResultResponse.kt b/src/main/kotlin/picklab/backend/participation/entrypoint/response/ActivityParticipationResultResponse.kt new file mode 100644 index 00000000..86461bd0 --- /dev/null +++ b/src/main/kotlin/picklab/backend/participation/entrypoint/response/ActivityParticipationResultResponse.kt @@ -0,0 +1,61 @@ +package picklab.backend.participation.entrypoint.response + +import io.swagger.v3.oas.annotations.media.Schema +import picklab.backend.participation.domain.entity.ActivityParticipation +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.domain.enums.ProgressStatus +import java.time.LocalDate +import java.time.LocalDateTime + +data class ActivityParticipationResultResponse( + @field:Schema(description = "활동 참여 ID") + val participationId: Long, + @field:Schema(description = "활동 ID") + val activityId: Long, + @field:Schema(description = "활동명") + val title: String, + @field:Schema(description = "주최 기관/단체명") + val organizer: String?, + @field:Schema(description = "활동 유형") + val activityType: String?, + @field:Schema(description = "활동 썸네일 이미지 URL") + val thumbnailUrl: String?, + @field:Schema(description = "지원 시작일") + val recruitmentStartDate: LocalDate, + @field:Schema(description = "지원 종료일") + val recruitmentEndDate: LocalDate?, + @field:Schema(description = "활동 시작일") + val activityStartDate: LocalDate, + @field:Schema(description = "활동 종료일") + val activityEndDate: LocalDate?, + @field:Schema(description = "지원 상태") + val applicationStatus: ApplicationStatus, + @field:Schema(description = "진행 상태") + val progressStatus: ProgressStatus, + @field:Schema(description = "리뷰 작성 가능 여부") + val canWriteReview: Boolean, + @field:Schema(description = "지원 완료 표시 일시") + val appliedAt: LocalDateTime, +) { + companion object { + fun from(participation: ActivityParticipation): ActivityParticipationResultResponse { + val activity = participation.activity + return ActivityParticipationResultResponse( + participationId = participation.id, + activityId = activity.id, + title = activity.title, + organizer = activity.organizer, + activityType = activity.activityType, + thumbnailUrl = activity.activityThumbnailUrl, + recruitmentStartDate = activity.recruitmentStartDate, + recruitmentEndDate = activity.recruitmentEndDate, + activityStartDate = activity.startDate, + activityEndDate = activity.endDate, + applicationStatus = participation.applicationStatus, + progressStatus = participation.progressStatus, + canWriteReview = participation.canWriteReview(), + appliedAt = participation.createdAt, + ) + } + } +} diff --git a/src/main/kotlin/picklab/backend/participation/entrypoint/response/ActivityParticipationSummaryResponse.kt b/src/main/kotlin/picklab/backend/participation/entrypoint/response/ActivityParticipationSummaryResponse.kt new file mode 100644 index 00000000..ba6477e3 --- /dev/null +++ b/src/main/kotlin/picklab/backend/participation/entrypoint/response/ActivityParticipationSummaryResponse.kt @@ -0,0 +1,14 @@ +package picklab.backend.participation.entrypoint.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ActivityParticipationSummaryResponse( + @field:Schema(description = "지원 완료로 표시한 전체 활동 수") + val appliedCount: Long, + @field:Schema(description = "최종 합격 활동 수") + val acceptedCount: Long, + @field:Schema(description = "불합격 활동 수") + val rejectedCount: Long, + @field:Schema(description = "수료 완료 활동 수") + val completedCount: Long, +) diff --git a/src/main/kotlin/picklab/backend/review/domain/repository/ReviewRepository.kt b/src/main/kotlin/picklab/backend/review/domain/repository/ReviewRepository.kt index 145cd8e3..eae6328c 100644 --- a/src/main/kotlin/picklab/backend/review/domain/repository/ReviewRepository.kt +++ b/src/main/kotlin/picklab/backend/review/domain/repository/ReviewRepository.kt @@ -8,4 +8,9 @@ interface ReviewRepository : JpaRepository { activityId: Long, memberId: Long, ): Boolean + + fun existsByActivityIdAndMemberIdAndDeletedAtIsNull( + activityId: Long, + memberId: Long, + ): Boolean } diff --git a/src/main/kotlin/picklab/backend/review/domain/service/ReviewService.kt b/src/main/kotlin/picklab/backend/review/domain/service/ReviewService.kt index 7fe1278c..1ec11234 100644 --- a/src/main/kotlin/picklab/backend/review/domain/service/ReviewService.kt +++ b/src/main/kotlin/picklab/backend/review/domain/service/ReviewService.kt @@ -19,6 +19,11 @@ class ReviewService( memberId: Long, ): Boolean = reviewRepository.existsByActivityIdAndMemberId(activityId, memberId) + fun existsActiveByActivityIdAndMemberId( + activityId: Long, + memberId: Long, + ): Boolean = reviewRepository.existsByActivityIdAndMemberIdAndDeletedAtIsNull(activityId, memberId) + fun mustFindById(id: Long): Review = reviewRepository .findById(id) diff --git a/src/test/kotlin/picklab/backend/participation/ActivityParticipationIntegrationTest.kt b/src/test/kotlin/picklab/backend/participation/ActivityParticipationIntegrationTest.kt new file mode 100644 index 00000000..86d03f0f --- /dev/null +++ b/src/test/kotlin/picklab/backend/participation/ActivityParticipationIntegrationTest.kt @@ -0,0 +1,227 @@ +package picklab.backend.participation + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import picklab.backend.activity.domain.entity.Activity +import picklab.backend.activity.domain.entity.ExternalActivity +import picklab.backend.activity.domain.enums.ActivityFieldType +import picklab.backend.activity.domain.enums.LocationType +import picklab.backend.activity.domain.enums.OrganizerType +import picklab.backend.activity.domain.enums.ParticipantType +import picklab.backend.activity.domain.enums.RecruitmentStatus +import picklab.backend.activity.domain.repository.ActivityRepository +import picklab.backend.activitygroup.domain.entity.ActivityGroup +import picklab.backend.activitygroup.domain.repository.ActivityGroupRepository +import picklab.backend.common.model.ErrorCode +import picklab.backend.common.model.SuccessCode +import picklab.backend.helper.WithMockUser +import picklab.backend.member.domain.entity.Member +import picklab.backend.member.domain.repository.MemberRepository +import picklab.backend.participation.domain.entity.ActivityParticipation +import picklab.backend.participation.domain.enums.ApplicationStatus +import picklab.backend.participation.domain.enums.ProgressStatus +import picklab.backend.participation.domain.repository.ActivityParticipationRepository +import picklab.backend.participation.entrypoint.request.UpdateApplicationStatusRequest +import picklab.backend.participation.entrypoint.request.UpdateProgressStatusRequest +import picklab.backend.template.IntegrationTest +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +class ActivityParticipationIntegrationTest : IntegrationTest() { + @Autowired + lateinit var memberRepository: MemberRepository + + @Autowired + lateinit var activityRepository: ActivityRepository + + @Autowired + lateinit var activityGroupRepository: ActivityGroupRepository + + @Autowired + lateinit var participationRepository: ActivityParticipationRepository + + lateinit var member: Member + lateinit var activity: Activity + + @BeforeEach + fun setUp() { + cleanUp.all() + + member = + memberRepository.save( + Member( + name = "테스트 유저", + email = "test@example.com", + ), + ) + + val activityGroup = + activityGroupRepository.save( + ActivityGroup( + name = "테스트 그룹", + description = "테스트 그룹 설명", + ), + ) + + activity = + activityRepository.save( + ExternalActivity( + title = "테스트 대외활동", + organizer = "테스트 주최", + organizerType = OrganizerType.PUBLIC_ORGANIZATION, + targetAudience = ParticipantType.WORKER, + location = LocationType.SEOUL_INCHEON, + recruitmentStartDate = LocalDate.now().minusDays(1), + recruitmentEndDate = LocalDate.now().plusMonths(1), + startDate = LocalDate.now().plusMonths(2), + endDate = LocalDate.now().plusMonths(3), + status = RecruitmentStatus.OPEN, + viewCount = 0L, + duration = + ChronoUnit.DAYS + .between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1)) + .toInt(), + activityHomepageUrl = null, + activityApplicationUrl = null, + activityThumbnailUrl = null, + description = "테스트 설명", + benefit = "테스트 혜택", + activityGroup = activityGroup, + activityField = ActivityFieldType.MENTORING, + ), + ) + } + + @Nested + @WithMockUser + @DisplayName("활동 참여 API") + inner class ActivityParticipationTests { + @Test + @DisplayName("[성공] 활동을 지원 완료로 표시한다") + fun createAppliedParticipation() { + mockMvc + .post("/v1/activities/${activity.id}/participations") + .andExpect { status { isCreated() } } + .andExpect { jsonPath("$.code") { value(SuccessCode.CREATE_ACTIVITY_PARTICIPATION.status.value()) } } + .andExpect { jsonPath("$.message") { value(SuccessCode.CREATE_ACTIVITY_PARTICIPATION.message) } } + + val participation = participationRepository.findByMemberIdAndActivityId(member.id, activity.id) + assertThat(participation).isNotNull + assertThat(participation!!.applicationStatus).isEqualTo(ApplicationStatus.APPLIED) + assertThat(participation.progressStatus).isEqualTo(ProgressStatus.NOT_SELECTED) + } + + @Test + @DisplayName("[실패] 이미 지원 완료 표시한 활동은 중복 표시할 수 없다") + fun cannotCreateDuplicateAppliedParticipation() { + participationRepository.save( + ActivityParticipation( + applicationStatus = ApplicationStatus.APPLIED, + progressStatus = ProgressStatus.NOT_SELECTED, + member = member, + activity = activity, + ), + ) + + mockMvc + .post("/v1/activities/${activity.id}/participations") + .andExpect { status { isBadRequest() } } + .andExpect { jsonPath("$.code") { value(ErrorCode.ALREADY_EXISTS_ACTIVITY_PARTICIPATION.status.value()) } } + .andExpect { jsonPath("$.message") { value(ErrorCode.ALREADY_EXISTS_ACTIVITY_PARTICIPATION.message) } } + } + + @Test + @DisplayName("[성공] 합격 여부와 수료 여부를 수정하고 현황을 조회한다") + fun updateStatusesAndGetSummary() { + val participation = + participationRepository.save( + ActivityParticipation( + applicationStatus = ApplicationStatus.APPLIED, + progressStatus = ProgressStatus.NOT_SELECTED, + member = member, + activity = activity, + ), + ) + + mockMvc + .patch("/v1/activity-participations/${participation.id}/application-status") { + contentType = MediaType.APPLICATION_JSON + content = + mapper.writeValueAsString( + UpdateApplicationStatusRequest(ApplicationStatus.ACCEPTED), + ) + }.andExpect { status { isOk() } } + + mockMvc + .patch("/v1/activity-participations/${participation.id}/progress-status") { + contentType = MediaType.APPLICATION_JSON + content = + mapper.writeValueAsString( + UpdateProgressStatusRequest(ProgressStatus.COMPLETED), + ) + }.andExpect { status { isOk() } } + + mockMvc + .get("/v1/activity-participations/summary") + .andExpect { status { isOk() } } + .andExpect { jsonPath("$.data.applied_count") { value(1) } } + .andExpect { jsonPath("$.data.accepted_count") { value(1) } } + .andExpect { jsonPath("$.data.rejected_count") { value(0) } } + .andExpect { jsonPath("$.data.completed_count") { value(1) } } + } + + @Test + @DisplayName("[실패] 최종 합격 상태가 아니면 수료 여부를 수정할 수 없다") + fun cannotUpdateProgressStatusWithoutAccepted() { + val participation = + participationRepository.save( + ActivityParticipation( + applicationStatus = ApplicationStatus.APPLIED, + progressStatus = ProgressStatus.NOT_SELECTED, + member = member, + activity = activity, + ), + ) + + mockMvc + .patch("/v1/activity-participations/${participation.id}/progress-status") { + contentType = MediaType.APPLICATION_JSON + content = + mapper.writeValueAsString( + UpdateProgressStatusRequest(ProgressStatus.COMPLETED), + ) + }.andExpect { status { isBadRequest() } } + .andExpect { jsonPath("$.code") { value(ErrorCode.CANNOT_UPDATE_ACTIVITY_PROGRESS_STATUS.status.value()) } } + .andExpect { jsonPath("$.message") { value(ErrorCode.CANNOT_UPDATE_ACTIVITY_PROGRESS_STATUS.message) } } + } + + @Test + @DisplayName("[성공] 지원 완료 표시를 취소한다") + fun cancelAppliedParticipation() { + participationRepository.save( + ActivityParticipation( + applicationStatus = ApplicationStatus.APPLIED, + progressStatus = ProgressStatus.NOT_SELECTED, + member = member, + activity = activity, + ), + ) + + mockMvc + .delete("/v1/activities/${activity.id}/participations") + .andExpect { status { isOk() } } + .andExpect { jsonPath("$.code") { value(SuccessCode.DELETE_ACTIVITY_PARTICIPATION.status.value()) } } + + assertThat(participationRepository.findByMemberIdAndActivityId(member.id, activity.id)).isNull() + } + } +} diff --git a/src/test/kotlin/picklab/backend/participation/domain/entity/ActivityParticipationTest.kt b/src/test/kotlin/picklab/backend/participation/domain/entity/ActivityParticipationTest.kt index 9bae6330..a21a88a9 100644 --- a/src/test/kotlin/picklab/backend/participation/domain/entity/ActivityParticipationTest.kt +++ b/src/test/kotlin/picklab/backend/participation/domain/entity/ActivityParticipationTest.kt @@ -12,6 +12,7 @@ import picklab.backend.member.domain.entity.Member import picklab.backend.participation.domain.enums.ApplicationStatus import picklab.backend.participation.domain.enums.ProgressStatus import java.time.LocalDate +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -61,6 +62,23 @@ class ActivityParticipationTest { .set(mockActivity, 1L) } + @Test + @DisplayName("미선택 상태는 리뷰 및 아카이브 작성이 불가능하다") + fun cannotWriteNotSelected() { + // given + val participation = + ActivityParticipation( + applicationStatus = ApplicationStatus.APPLIED, + progressStatus = ProgressStatus.NOT_SELECTED, + member = mockMember, + activity = mockActivity, + ) + + // when & then + assertFalse(participation.canWriteReview()) + assertFalse(participation.canArchive()) + } + @Test @DisplayName("진행 중일 때는 리뷰 및 아카이브 작성이 불가능하다") fun cannotWriteInProgressing() { @@ -111,4 +129,24 @@ class ActivityParticipationTest { assertTrue(participation.canWriteReview()) assertFalse(participation.canArchive()) } + + @Test + @DisplayName("불합격으로 변경하면 진행 상태는 미선택으로 초기화된다") + fun resetProgressStatusWhenRejected() { + // given + val participation = + ActivityParticipation( + applicationStatus = ApplicationStatus.ACCEPTED, + progressStatus = ProgressStatus.COMPLETED, + member = mockMember, + activity = mockActivity, + ) + + // when + participation.updateApplicationStatus(ApplicationStatus.REJECTED) + + // then + assertEquals(ApplicationStatus.REJECTED, participation.applicationStatus) + assertEquals(ProgressStatus.NOT_SELECTED, participation.progressStatus) + } }